diff --git a/.gib b/.gib new file mode 100644 index 0000000..ca1df07 --- /dev/null +++ b/.gib @@ -0,0 +1,5 @@ +{ + "name": "gib-cli", + "description": "git remote helper for gib, on-chain git on BSV", + "defaultBranch": "feat/gib" +} 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/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c0f461d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,88 @@ +# Working on gib-cli + +Read `README.md` first: it states the model, and the model is not up for +re-litigation here. This file is about how to work in the repository. + +## Ground rules + +- `bun run typecheck` and `bun test` must both pass before anything is committed. +- **No network, no wallet, no chain in tests.** `test/fakes/` has a BRC-100 wallet that + funds, signs and verifies in memory, a gib peer that serves the BRC-180 manifest, both + BRC-24 lookups and the BRC-22 submit route, and git helpers. Anything that would touch + mainnet belongs in a fake. +- Never perform an on-chain transaction or call a real wallet while developing. +- Say **"repository origin"** for the genesis `ordfs/dir` root outpoint, in code, + comments, errors and commit messages. Never bare "origin": ordinals have origins and + git has a remote named `origin`. +- Outpoints are `txid_vout` everywhere they are written down (`parseOutpoint` also reads + `txid.vout`, because BRC-100 wallets use the dot form). +- Comments explain *why*. The what is in the code. + +## What is where + +| Area | Files | +| --- | --- | +| remote URLs, discovery, peer client | `src/remote/url.ts`, `discover.ts`, `peer.ts` | +| syncing, ref naming, helper protocol | `src/remote/sync.ts`, `advertise.ts`, `helper.ts` | +| planning a tree, packing it into transactions | `src/cascade.ts`, `src/chain.ts` | +| push, fetch, init | `src/push.ts`, `src/fetch.ts`, `src/init.ts` | +| published trees, and the `.git` store | `src/tree.ts` | +| head token | `src/token.ts`, `src/seal.ts`, `src/head.ts`, `src/publish.ts` | +| byte formats | `src/ordfs/` | +| local state | `src/txstore.ts`, `src/refs.ts`, `src/identity.ts`, `src/pending.ts` | + +## Things that will bite you + +- **`.git` in a published root is not a git directory.** It is the repository's object + store, keyed by sha: commit objects as files, their trees as directories, and a `.` + default entry aliasing the tip commit. `stripGitDir` removes it, and every path that + turns a published tree back into git's tree must call it or the sha will not match. +- **Ancestor trees are not optional.** git's connectivity check is + `git rev-list --objects`, which walks commit to tree to blob; a history missing one + ancestor tree is one git refuses to fetch. Test it with `git fsck --strict`. +- **A same-transaction directory reference is one byte.** A transaction holds at most + 256 outputs. `chain.ts` packs a plan across as many transactions as it needs, laying + nodes down in dependency order, so nothing has to fit in one. +- **A patch needs a base with a txid.** Bytes still waiting in the transaction being + built cannot be patched against, so they are written whole. Identical content is a + citation, never a no-op patch. +- **A head has no inscription.** It is a bare PushDrop of six fields plus the signature. + Reading the commit it publishes means reading its root's `.git`, which costs content — + so `list` resolves the sha for a branch's newest head only, never for every head on + the chain. +- **A directory reached twice is not a cycle.** Ancestor trees share every subdirectory + that has not changed. A cycle is a directory that contains itself. +- **A loose git object is written read-only.** Never rewrite one that exists. +- **`list for-push` must advertise the peer's view**, not everything this client knows, + or git will decide the remote already has a head that was only ever minted locally and + send nothing. +- **The wallet's basket is spend authority, not a ref list.** Refs come from the store + and `src/refs.ts`; the basket is only consulted to find the head this wallet may spend. +- BRC-24 answers carry `result` as a JSON document encoded *into a string*: parse twice. +- The client never asks a third party for a repository's transactions. There is no + default gateway; the only host it contacts is the one in the `gib://` URL (resolved + through BRC-180). + +## Known gaps + +- `ls_gib` has no query that enumerates a repository's branches. `branchCandidates` in + `src/remote/sync.ts` guesses from what is already known, the genesis tree's `.gib` + `defaultBranch`, and `main`/`master`; `gib sync ` is how a user names one + it could not guess. That is why `gib init` still writes `defaultBranch`, and it is the + one gap with a visible cost to users. +- A push reads every reachable commit's tree it has not published before, one + `git ls-tree` at a time, and holds the tree being planned in memory. The first push of + a long history is bounded by that. +- Every push walks the peer's whole branch twice — once for `list for-push`, once in + `syncPeer` — because both start from an empty cursor on purpose. Correct, but linear in + history, for ever. +- A `.git` manifest lists every reachable commit and tree, so it is rewritten in full on + every push and grows linearly with history. `ordfs/dir` counts entries in a uint16, so + a repository is capped at ~32k commits (two entries each). +- An octopus merge of three or more parents only fits two lineages in a head: the spend + and the one `branchedFrom` field. +- A file edited in N commits ends up behind an N-deep patch chain; there is no + "rewrite it whole after N" policy, and `prefetchTree` stops following at depth 64. +- `headsSince` says nothing about whether a branch's last head has been spent, so a + branch deleted by burning its head still advertises to anyone who learns about it from + a peer rather than from their own delete. See the TODO on `pullBranch`. diff --git a/README.md b/README.md index 5eba41c..de8d544 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,148 @@ # gib +> **Deprecated.** This TypeScript implementation is superseded by the Go one at +> [b-open-io/gib](https://github.com/b-open-io/gib), which runs the gib overlay +> locally, speaks the same on-chain formats, and is the supported `git-remote-gib`. +> Published on chain as repository origin +> `6d46f3406ee04d1b9b1379d0c012b6b0bb8ff0bc18f19d4709f9f6cefb94569d_82`. +> This repository is kept for history only and receives no further work. + On-chain git for BSV. Content is write-once chain outputs; directories are `ordfs/dir` -inscriptions; branch pointers are sealed push-drop coins ("commit tokens"); gib sits on -top of local git as the chain codec + pointer/authority layer via a `git-remote-gib` -remote helper. Git stays git — only push/fetch touch the chain. +manifests; a branch is a chain of sealed push-drop coins ("commit heads"), one per push. +gib sits on top of local git as the chain codec plus pointer/authority layer, via a +`git-remote-gib` remote helper. Git stays git — only push and fetch touch the chain. + +## The model + +**One head per push.** A push mints a single head token, spending the branch's previous +head. Commits are hash-linked, so a signature over the tip commits to every ancestor: a +head per commit would buy nothing and cost a transaction each. + +**The published root is git's tree, plus `.git`.** One extra entry on the root holds the +repository's own object store, keyed by sha: every commit object reachable from the tip, +and every one of those commits' trees. Strip that entry and what is left is byte for +byte the tree git hashed, which is why the commit still verifies — see `stripGitDir`, +the only place the model bends. + +The store holds ancestors' *trees* and not only their commit objects because git will +not accept a history without them: its connectivity check walks commit to tree to blob, +so a fetch missing one ancestor tree is a fetch git rejects. It also carries a `.` +default entry aliasing the tip commit object, which is how a reader learns which commit +a head publishes without reading the whole store. + +**Names are shas, so nothing is published twice.** A commit or a tree already on chain +is cited at the outpoint holding it, exactly like an unchanged file. Branching from +someone else's head therefore copies none of their objects, and an incremental fetch +skips every name git already has. + +**The head token** is a bare 1-satoshi PushDrop — nothing is inscribed beside it: +fields `["gib", , , , , ]`, +protocol `[1, "gib branch"]`, keyID = the root outpoint, counterparty `anyone`, basket +`gib`, labels `gib push` / `gib delete`, tags `origin:`, `branch:`, +`commit:`, `randomizeOutputs: false`. + +The sixth field is empty on an ordinary push, set on a branch's first head (naming the +head it forked from) and on a merge (naming a head publishing the second parent, while +the spend covers the first). A head's parents mirror its commit's parents by +construction. The five-field heads with an inscribed commit that gib published before +this are a different format and do not decode: a clean break, no compatibility path. + +**The client holds no overlay.** No engine, no database, no topic manager, no chain +tracker; the client never validates a merkle proof. It keeps the transactions it has +been given in `$GIB_HOME/txstore` and, beside them, the newest head it has seen per +`(identity, branch)` in `$GIB_HOME/repos/.json`. The peer's overlay +validates; the client asked it for what it got. + +**Remotes are peers.** A remote URL is `gib:///`, or +`gib://` for local only. "Repository origin" is always the genesis +`ordfs/dir` root outpoint — never bare "origin", which ordinals and git both already +use for something else. + +**Syncing is the lookup service.** Two BRC-24 questions on `ls_gib`: `headsSince` walks +one branch's heads from a point forward, oldest first, each with its own BEEF; `txs` +fetches whole transactions by txid (at most 50) as one merged BEEF. Publishing is a +BRC-22 submission of an atomic BEEF to `tm_gib`. + +**Discovery is BRC-180.** A host is resolved by fetching `https:///manifest.json` +and reading `metanet.overlays`: `tm_gib` is the submit endpoint, `ls_gib` the lookup +endpoint, each used verbatim. A host with no manifest, or no entry for gib, is treated +as the overlay itself at `/1sat/gib/overlay` — that is not probing, it is contacting +exactly the host the user named. `gibhub.net` publishes +`https://api.1sat.app/1sat/gib/overlay` for both; `api.1sat.app` serves no manifest and +works through the fallback. + +**Ref naming.** Heads signed by this wallet advertise as `refs/heads/`; every +other publisher's as `refs/heads/@<66-hex identity>/`. Pushing an `@…` ref is +refused. The identity is cached in `$GIB_HOME/identity`, so listing works with no +wallet; with neither wallet nor cache, nothing is bare. `HEAD` resolves to the genesis +head's branch — the earliest head on the repository origin, the one whose root *is* the +origin. + +**`.gib`** carries a name and a description, and for now a `defaultBranch`: no lookup +enumerates a repository's branches, so a clone that has never heard of a repository has +nothing else to ask a peer for. The file may one day also carry publishing hints — patch +depth, outputs per transaction, stream sizes — but those would be hints a client may +honour, not rules anyone can enforce. None is implemented. + +## Use it + +```bash +bun install +ln -s "$PWD/src/git-remote-gib.ts" ~/.local/bin/git-remote-gib # git finds helpers on PATH +ln -s "$PWD/src/main.ts" ~/.local/bin/gib + +cd my-project +git init && git add -A && git commit -m init # gib init needs a commit +gib init # mints the repository; writes .gib +git remote add gib gib://gibhub.net/ +git push gib main # publishes it to that peer + +git clone gib://gibhub.net/ # anyone, no wallet needed +``` + +`gib init` creates the repository and nothing else does: a push joins the repository its +URL names and never mints a second one. It adds a `local` remote (`gib://`, the +local store only) and prints the peer remote to add. + +Other commands: `gib sync [branch...]` refreshes a repository from its peer — +naming a branch teaches this client one it could not otherwise discover, which is how +you pick up a branch someone else created — `gib doctor` checks the wallet and store, +and `gib put ` stores a signed transaction. + +Publishing needs a BRC-100 wallet on `http://127.0.0.1:3321` (`1sat serve wallet-api`, +or set `GIB_WALLET_URL`) and its monitor running (`1sat serve monitor`) so delayed +broadcasts go out. Reading needs no wallet at all. + +## Environment + +| Variable | Meaning | +| --- | --- | +| `GIB_HOME` | store, per-repository state, identity cache (default `~/.gib`) | +| `GIB_WALLET_URL` | BRC-100 wallet endpoint (default `http://127.0.0.1:3321`) | + +## Layout + +- `src/remote/` — `gib://` URLs, BRC-180 discovery, the peer client (both lookups and + submit), syncing, ref naming, and the remote-helper protocol. +- `src/cascade.ts` — planning one commit's tree into nodes; `src/chain.ts` — packing + those nodes into transactions. +- `src/push.ts` — building the root and minting the head; `src/fetch.ts` — turning a + published root back into git objects. +- `src/tree.ts` — reading a published tree, and `stripGitDir`. +- `src/ordfs/` — the `ordfs/dir` and `ordfs/patch` codecs and vcdiff. +- `src/token.ts`, `src/seal.ts`, `src/head.ts` — the head token: fields, sealing, reading. +- `test/fakes/` — a fake BRC-100 wallet, a fake gib peer, and git helpers. Tests never + touch a network, a real wallet or a chain. + +## Reading -**Start here:** -- `docs/plans/ROADMAP.md` — workstreams, build order, settled decisions. - `docs/plans/ordfs-formats.html` — the `ordfs/dir` / `ordfs/patch` byte specs (the - contract between 1sat-stack, 1sat-sdk, and gib). + contract between 1sat-stack, 1sat-sdk and gib). - `docs/plans/gib-token.html`, `gib-cli.html`, `gib-format.html`, `gib-rationale.html` — - 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. -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 -fields) — read it for the wallet/BRC-100 mechanics that work, not for architecture. - -Related repos: `b-open-io/1sat-sdk` (dir/patch encoding + push-drop lifecycle -abstraction land there directly; opldotdev is the same repo under rename), -`b-open-io/1sat-stack` (gateway serving the new content types). + design. `gib-status.html` — what was proven on mainnet. +- `docs/plans/ROADMAP.md`, `docs/plans/questions.md` — sequencing and open items. +- BRC-180 (overlay service discovery at an internet domain) for the manifest. + +Related repos: `b-open-io/1sat-sdk` (dir/patch encoding and push-drop lifecycle), +`b-open-io/1sat-stack` (the gib overlay: `tm_gib`, `ls_gib`, and the gateway serving +the content types). diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..436cc85 --- /dev/null +++ b/bun.lock @@ -0,0 +1,87 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "gib-cli", + "dependencies": { + "@1sat/actions": "0.0.224", + "@1sat/client": "^0.0.55", + "@1sat/templates": "^0.0.38", + "@bsv/sdk": "^2.6.0", + "xdelta3-wasm": "^1.0.0", + }, + "devDependencies": { + "@types/bun": "^1.2.0", + "typescript": "^5.9.3", + }, + }, + }, + "packages": { + "@1sat/actions": ["@1sat/actions@0.0.224", "", { "dependencies": { "@1sat/client": "0.0.55", "@1sat/templates": "0.0.38", "@1sat/types": "0.0.48", "@1sat/utils": "0.0.38", "@1sat/wallet": "0.0.111", "@bsv/message-box-client": "^2.5.0", "xdelta3-wasm": "^1.0.0" }, "peerDependencies": { "@bsv/sdk": "^2.6.0" } }, "sha512-tf0kNFVSj5Kb4rk6qowsdQaOYmFQPGI0mtSyXchA5lEj9lMfvMnfjAZ5aejK0jQpJ6OoRqjPUNRSzlR4H3+/mw=="], + + "@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.38", "", { "dependencies": { "@1sat/types": "0.0.48", "cbor2": "^2.3.0" }, "peerDependencies": { "@bsv/sdk": "^2.6.0" } }, "sha512-vrWKd98pITkeKGfzWgvwxLs1QaIS8KVQwVrn+c3c9mNMAxyCvnIeN+9xnwXY8FCORsC5IQhrMjUa5/pTa7Z19g=="], + + "@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.111", "", { "dependencies": { "@1sat/client": "0.0.55", "@1sat/templates": "0.0.38", "@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-8Jj+IIyPiRTxIYCa4WGh0LzTx9yvdNUy0zPVps4chz3t0vND7mVQ4NSiRW5RnbPIK8n6EFL2lmg7JLcYmb9x1g=="], + + "@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.2", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g=="], + + "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/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/gib-cli.html b/docs/plans/gib-cli.html index a34bc87..c9ba048 100644 --- a/docs/plans/gib-cli.html +++ b/docs/plans/gib-cli.html @@ -131,11 +131,52 @@

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

from files in the working tree. +

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

+

Git has no notion of a repository name; hosts keep that in their own database. +gib keeps it in the tree: a committed dotfile .gib at the root, JSON, +published and cited like any other file. It is read from the genesis tree +only (the origin outpoint): the name is part of the repository's identity, +fixed for its life and identical for every head anyone mints against that origin. +Editing .gib in a later commit changes the file in the tree but not what +indexers report; renaming a repository means publishing a new genesis (a self-fork).

+
{
+  "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/<origin>/.gib once per +repository and store name and default branch alongside each 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 +209,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/ordfs-formats.html b/docs/plans/ordfs-formats.html index 53244a2..1bb13ba 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 @@ -104,6 +105,28 @@

        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. 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). 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
      +
        +
      • 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/docs/plans/questions.md b/docs/plans/questions.md index 369c994..e34ced3 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,13 @@ 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), + read from the GENESIS tree only (fixed at origin; rename = new origin); labels not + identifiers; 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 new file mode 100644 index 0000000..2777893 --- /dev/null +++ b/package.json @@ -0,0 +1,27 @@ +{ + "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", + "typecheck": "tsc --noEmit", + "gib": "bun run src/main.ts", + "git-remote-gib": "bun run src/git-remote-gib.ts" + }, + "dependencies": { + "@1sat/actions": "0.0.224", + "@1sat/client": "^0.0.55", + "@1sat/templates": "^0.0.38", + "@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..43f1d3c --- /dev/null +++ b/src/cascade.ts @@ -0,0 +1,395 @@ +/** + * The cascade: turning one commit's file list into the outputs that + * publish its tree. + * + * Only what changed is written. An unchanged file is cited at the outpoint + * that already holds it; a changed file whose previous bytes live at a real + * outpoint becomes an `ordfs/patch` against it; everything else is written + * whole. Directory manifests are rebuilt from the leaves up, and a + * directory nothing touched is cited rather than rewritten. + * + * Nothing here decides which transaction an output lands in. A plan is a + * list of nodes in dependency order — children before the directory that + * names them — with references by node, not by vout. Packing them into + * transactions is `chain.ts`'s job, which is what lets one push carry + * several commits' trees, and lets a tree spill across transactions when + * it is too big for one. + */ + +import { + DIR_CONTENT_TYPE, + type DirEntry, + type DirRef, + dirEncode, + dirName, +} from './ordfs/dir.ts' +import { PATCH_CONTENT_TYPE, patchFromContent } from './ordfs/patch.ts' +import { formatOutpoint, type Outpoint } from './outpoint.ts' +import { collectSnapshot, GIT_DIR } from './tree.ts' +import { resolveOutpoint } from './resolver.ts' +import { dirDecode, dirNameString } from './ordfs/dir.ts' +import type { TxStore } from './txstore.ts' + +export type IncomingFile = { + path: string + bytes: Uint8Array + contentType?: string + exec?: boolean + symlink?: boolean +} + +/** Where a directory entry points while a plan is still being built. */ +export type PlanRef = { kind: 'node'; id: number } | DirRef + +export type PlanEntry = { + name: string + isDir: boolean + exec?: boolean + symlink?: boolean + ref: PlanRef +} + +export type PlanNode = + | { kind: 'data'; contentType: string; bytes: Uint8Array; label: string } + | { kind: 'dir'; entries: PlanEntry[]; label: string } + +/** A plan under construction: nodes in dependency order. */ +export class Plan { + readonly nodes: PlanNode[] = [] + + add(node: PlanNode): number { + this.nodes.push(node) + return this.nodes.length - 1 + } + + get size(): number { + return this.nodes.length + } +} + +/** One published file: its bytes and where the tree points at them. */ +export type TreeFile = { + bytes: Uint8Array + ref: PlanRef + exec?: boolean + symlink?: boolean + /** Set once the bytes live in a transaction with a known txid. */ + outpoint?: Outpoint +} + +/** A tree, as the next commit in a push needs to see it. */ +export type Tree = { + files: Map + dirs: Map +} + +export type CommitPlan = { + /** The node holding this commit's root directory. */ + rootId: number + /** The tree those nodes publish. */ + tree: Tree +} + +/** The largest vout an `ordfs/dir` same-transaction reference can name. */ +export const MAX_SAME_TX_VOUT = 255 + +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 +} + +/** What a published root holds: git's tree, and the `.git` object store. */ +export type PublishedRoot = { + tree: Tree + /** `.git` entries by name — commit shas and tree shas — as references. */ + objects: Map +} + +/** + * Read a published root for planning the next push: the tip's tree in + * full, and the `.git` store as references only. + * + * The object store is deliberately not descended into. Its entries are + * named by sha, so a name is proof of content: to cite an object already + * on chain gib needs its reference, never its bytes. Reading them would + * mean pulling every version of every file in the repository's history + * into memory on every push. + */ +export async function loadPublishedRoot( + store: TxStore, + root: Outpoint, +): Promise { + const node = await resolveOutpoint(store, root) + if (node.contentType !== DIR_CONTENT_TYPE) { + throw new Error(`published root ${formatOutpoint(root)} is not a directory`) + } + const manifest = dirDecode(node.bytes) + const files = new Map() + const dirs = new Map() + const objects = new Map() + for (const e of manifest.entries) { + const name = dirNameString(e.name) + const ref: DirRef = + e.ref.kind === 'same-tx' + ? { kind: 'outpoint', txid: root.txid, vout: e.ref.vout } + : { kind: 'outpoint', txid: e.ref.txid.toLowerCase(), vout: e.ref.vout } + const child: Outpoint = { txid: ref.txid, vout: ref.vout } + if (name === GIT_DIR) { + const store0 = await resolveOutpoint(store, child) + for (const o of dirDecode(store0.bytes).entries) { + const oref: DirRef = + o.ref.kind === 'same-tx' + ? { kind: 'outpoint', txid: child.txid, vout: o.ref.vout } + : { kind: 'outpoint', txid: o.ref.txid.toLowerCase(), vout: o.ref.vout } + objects.set(dirNameString(o.name), { ref: oref, isDir: o.isDir }) + } + continue + } + if (e.isDir) { + const sub = await collectSnapshot(store, child, name) + for (const f of sub.files) { + files.set(f.path, { + bytes: f.bytes, + ref: { kind: 'outpoint', txid: f.outpoint.txid, vout: f.outpoint.vout }, + exec: f.exec, + symlink: f.symlink, + outpoint: f.outpoint, + }) + } + for (const [path, op] of sub.dirs) { + dirs.set(path, { kind: 'outpoint', txid: op.txid, vout: op.vout }) + } + continue + } + const file = await resolveOutpoint(store, child) + files.set(name, { + bytes: file.bytes, + ref, + exec: e.exec, + symlink: e.symlink, + outpoint: child, + }) + } + return { tree: { files, dirs }, objects } +} + +/** Direct child names of a directory, from a set of paths. */ +function childNames(paths: Iterable, dir: string): Set { + const out = new Set() + for (const p of paths) { + if (p !== '' && parentDir(p) === dir) out.add(basename(p)) + } + return out +} + +function sameNames(a: Set, b: Set): boolean { + if (a.size !== b.size) return false + for (const x of a) if (!b.has(x)) return false + return true +} + +/** + * Plan the outputs that publish one commit's tree, appending them to + * `plan`. The returned root node is git's tree for that commit and nothing + * else — the `.git` store is added to the tip's root separately, by the + * caller, so that every other commit's tree stays exactly what git hashed. + */ +export async function planCommit(opts: { + files: IncomingFile[] + /** The tree a previous commit published, when there is one to cite. */ + prev?: Tree + plan: Plan +}): Promise { + const plan = opts.plan + const prevFiles = opts.prev?.files ?? new Map() + const prevDirs = opts.prev?.dirs ?? new Map() + + const fileRef = new Map() + const changed = new Set() + + for (const f of opts.files) { + const prev = prevFiles.get(f.path) + const modeSame = + !!prev && !!prev.exec === !!f.exec && !!prev.symlink === !!f.symlink + if (prev && bytesEqual(prev.bytes, f.bytes)) { + // Identical content is a citation, never a no-op patch. A mode + // change still rewrites the parent directory (flags live there). + fileRef.set(f.path, prev.ref) + if (!modeSame) changed.add(f.path) + continue + } + changed.add(f.path) + if (prev?.outpoint) { + // A patch needs a base that already has a txid; bytes still + // waiting in this same push cannot be one, so they are written + // whole. + const bytes = await patchFromContent({ + base: prev.outpoint, + source: prev.bytes, + target: f.bytes, + }) + fileRef.set(f.path, { + kind: 'node', + id: plan.add({ + kind: 'data', + contentType: PATCH_CONTENT_TYPE, + bytes, + label: f.path, + }), + }) + continue + } + fileRef.set(f.path, { + kind: 'node', + id: plan.add({ + kind: 'data', + contentType: f.contentType ?? 'application/octet-stream', + bytes: f.bytes, + label: f.path, + }), + }) + } + + // Every directory of the new tree, and what sits directly in each. + const dirs = new Set(['']) + for (const f of opts.files) { + let d = parentDir(f.path) + for (;;) { + dirs.add(d) + if (!d) break + d = parentDir(d) + } + } + const children = new Map>() + const add = (parent: string, path: string) => { + let set = children.get(parent) + if (!set) { + set = new Set() + children.set(parent, set) + } + set.add(path) + } + for (const d of dirs) if (d) add(parentDir(d), d) + for (const f of opts.files) add(parentDir(f.path), f.path) + + // A directory is rebuilt when a child of it changed, when its child set + // differs from the tree the previous commit published, or when it is new. + // A rebuilt directory changes its parent's reference, so that cascades up. + const prevPaths = [...prevFiles.keys(), ...prevDirs.keys()] + const touched = new Set(['']) + for (const d of dirs) { + const wasThere = d === '' || prevDirs.has(d) + if ( + !opts.prev || + !wasThere || + !sameNames( + childNames([...(children.get(d) ?? [])], d), + childNames(prevPaths, d), + ) + ) { + touched.add(d) + } + } + for (const path of changed) touched.add(parentDir(path)) + for (const d of [...touched]) { + let p = d + while (p) { + p = parentDir(p) + touched.add(p) + } + } + + const deepestFirst = [...dirs].sort( + (a, b) => + b.split('/').filter(Boolean).length - a.split('/').filter(Boolean).length, + ) + const byPath = new Map(opts.files.map((f) => [f.path, f])) + const dirRef = new Map() + for (const d of deepestFirst) { + if (!touched.has(d)) { + const cited = prevDirs.get(d) + if (cited) { + dirRef.set(d, cited) + continue + } + } + const entries: PlanEntry[] = [] + for (const path of [...(children.get(d) ?? [])].sort()) { + const name = basename(path) + if (dirs.has(path)) { + const ref = dirRef.get(path) + // Children are planned before their parent, so this is a bug + // rather than a case: dropping the entry silently would + // publish a tree missing a whole subdirectory. + if (!ref) throw new Error(`cascade: no reference for directory ${path}`) + entries.push({ name, isDir: true, ref }) + continue + } + const ref = fileRef.get(path) + if (!ref) throw new Error(`cascade: no reference for file ${path}`) + const file = byPath.get(path) + entries.push({ + name, + isDir: false, + exec: file?.exec, + symlink: file?.symlink, + ref, + }) + } + dirRef.set(d, { + kind: 'node', + id: plan.add({ kind: 'dir', entries, label: d || '/' }), + }) + } + + const root = dirRef.get('') + if (!root || root.kind !== 'node') throw new Error('cascade: missing root dir') + + const tree: Tree = { + files: new Map( + opts.files.map((f) => { + const ref = fileRef.get(f.path) as PlanRef + return [ + f.path, + { + bytes: f.bytes, + ref, + exec: f.exec, + symlink: f.symlink, + outpoint: + ref.kind === 'outpoint' ? { txid: ref.txid, vout: ref.vout } : undefined, + }, + ] + }), + ), + dirs: new Map(dirRef), + } + return { rootId: root.id, tree } +} + +/** Encode a planned directory once every reference is a real one. */ +export function encodePlannedDir(entries: DirEntry[]): Uint8Array { + return dirEncode({ version: 1, entries }) +} + +/** A planned entry with its reference resolved, ready to encode. */ +export function toDirEntry(entry: PlanEntry, ref: DirRef): DirEntry { + return { + name: dirName(entry.name), + isDir: entry.isDir, + exec: entry.exec, + symlink: entry.symlink, + ref, + } +} diff --git a/src/chain.ts b/src/chain.ts new file mode 100644 index 0000000..9c7d886 --- /dev/null +++ b/src/chain.ts @@ -0,0 +1,153 @@ +/** + * Packing a plan into transactions. + * + * A plan is nodes in dependency order with references by node. Packing + * assigns each node an output: nodes are laid down in order, a transaction + * at a time, and a reference is a same-transaction vout when its target + * landed in the same transaction and a full outpoint when it landed in an + * earlier one. That ordering is what makes it safe — a node's dependencies + * are always before it, so they are already placed. + * + * A same-transaction reference is one byte, so a transaction carries at + * most 256 outputs. Nothing about a tree has to fit in one transaction: + * when the next node will not fit, the transaction is published and the + * rest carries on in a new one, citing what came before by outpoint. + */ + +import { + encodePlannedDir, + MAX_SAME_TX_VOUT, + type Plan, + type PlanRef, + toDirEntry, +} from './cascade.ts' +import { DIR_CONTENT_TYPE, type DirRef } from './ordfs/dir.ts' +import type { Outpoint } from './outpoint.ts' +import type { PlannedOutput, PublishedTx } from './publish.ts' +import { bLockingScript } from './script.ts' +import type { TxStore } from './txstore.ts' +import { Transaction } from '@bsv/sdk' + +/** Same-transaction vouts are one byte, so 256 outputs is the ceiling. */ +export const MAX_CONTENT_OUTPUTS = MAX_SAME_TX_VOUT + 1 + +export type PackedContent = { + /** Where each planned node ended up. */ + outpoints: Map + /** The transactions published, in order. */ + txs: PublishedTx[] + /** Transactions reused from a previous, interrupted attempt. */ + reused: number +} + +export type PackOptions = { + plan: Plan + store: TxStore + /** Publishes one transaction's worth of outputs. */ + publish: (outputs: PlannedOutput[]) => Promise + /** Content transactions from an interrupted push, in order. */ + pending?: PublishedTx[] + maxOutputs?: number + onContent?: (txs: PublishedTx[]) => Promise + log?: (s: string) => void +} + +export async function packContent(opts: PackOptions): Promise { + const max = Math.min(opts.maxOutputs ?? MAX_CONTENT_OUTPUTS, MAX_CONTENT_OUTPUTS) + const nodes = opts.plan.nodes + const outpoints = new Map() + const txs: PublishedTx[] = [] + let reused = 0 + + for (let start = 0; start < nodes.length; start += max) { + const end = Math.min(start + max, nodes.length) + const here = new Map() + for (let id = start; id < end; id++) here.set(id, id - start) + + const resolve = (ref: PlanRef): DirRef => { + if (ref.kind !== 'node') return ref + const vout = here.get(ref.id) + if (vout !== undefined) return { kind: 'same-tx', vout } + const op = outpoints.get(ref.id) + if (!op) { + throw new Error(`pack: node ${ref.id} referenced before it was placed`) + } + return { kind: 'outpoint', txid: op.txid, vout: op.vout } + } + + const outputs: PlannedOutput[] = [] + for (let id = start; id < end; id++) { + const node = nodes[id] + outputs.push( + node.kind === 'data' + ? { contentType: node.contentType, bytes: node.bytes, path: node.label } + : { + contentType: DIR_CONTENT_TYPE, + bytes: encodePlannedDir( + node.entries.map((e) => toDirEntry(e, resolve(e.ref))), + ), + path: node.label, + }, + ) + } + + const reuse = matchPending(opts.pending?.[txs.length], outputs) + const tx = reuse ?? (await opts.publish(outputs)) + if (reuse) { + reused++ + opts.log?.(`gib: reusing content transaction ${reuse.txid}\n`) + } else if (!carriesOutputs(tx, outputs)) { + // Every same-transaction reference just encoded is a raw vout. A + // wallet that reordered the outputs, or put change anywhere but + // last, would leave every directory pointing at the wrong thing + // — and the push would report success. Refuse before any of it + // is used. + throw new Error( + `wallet returned ${tx.txid} without the planned outputs in order (randomizeOutputs must be honoured)`, + ) + } + await opts.store.put(tx.txid, tx.bytes) + txs.push(tx) + // Record it now, not at the end: a push interrupted after this + // transaction must not pay to publish the same content again. + await opts.onContent?.([...txs]) + for (let id = start; id < end; id++) { + outpoints.set(id, { txid: tx.txid, vout: id - start }) + } + } + return { outpoints, txs, reused } +} + +/** + * True when a transaction carries exactly these outputs, in order, from + * vout 0. The wallet's change output sits after them. + */ +function carriesOutputs( + published: PublishedTx, + outputs: PlannedOutput[], +): boolean { + let tx: Transaction + try { + tx = Transaction.fromBinary(Array.from(published.bytes)) + } catch { + return false + } + if (tx.outputs.length < outputs.length) return false + for (let i = 0; i < outputs.length; i++) { + const want = bLockingScript(outputs[i].contentType, outputs[i].bytes).toHex() + if (tx.outputs[i].lockingScript.toHex() !== want) return false + } + return true +} + +/** + * A content transaction from an interrupted push is reused only when it + * carries exactly the outputs now planned, in order. + */ +function matchPending( + candidate: PublishedTx | undefined, + outputs: PlannedOutput[], +): PublishedTx | undefined { + if (!candidate) return undefined + return carriesOutputs(candidate, outputs) ? candidate : undefined +} diff --git a/src/content.ts b/src/content.ts new file mode 100644 index 0000000..1876f81 --- /dev/null +++ b/src/content.ts @@ -0,0 +1,26 @@ +/** + * Content payloads: the bytes and media type of an on-chain output, whether + * it is an ordinal inscription (ord envelope) or a B data output. Decoding + * is the SDK's; gib only names what it needs from the result. + */ +import { B, Inscription } from '@1sat/templates' +import type { LockingScript, Script } from '@bsv/sdk' + +export type Payload = { + contentType: string + bytes: Uint8Array +} + +export function payloadFromScript( + script: LockingScript | Script, +): Payload | undefined { + const insc = Inscription.decode(script) + if (insc) { + return { contentType: insc.file.type, bytes: insc.file.content } + } + const b = B.decode(script) + if (b) { + return { contentType: String(b.mediaType), bytes: new Uint8Array(b.data) } + } + return undefined +} diff --git a/src/fetch.ts b/src/fetch.ts new file mode 100644 index 0000000..0ceae33 --- /dev/null +++ b/src/fetch.ts @@ -0,0 +1,167 @@ +/** + * `git fetch` for gib: turning a published root back into git objects. + * + * A head points at one root, and that root carries the whole history: git's + * tree for the tip commit, plus a `.git` store holding every commit object + * reachable from that tip and every one of those commits' trees, each named + * by its sha. So a fetch is one resolve, not a walk along a chain of heads. + * + * Names in the store are shas, which means git already having an object is + * proof it needs nothing from that entry — an incremental fetch skips + * everything it has and pulls only what is new. + */ + +import { gitHash, writeGitObject } from './git.ts' +import { hasObject as gitHasObject, treeShaFromCommit } from './gitread.ts' +import { readHead } from './head.ts' +import { formatOutpoint, type Outpoint } from './outpoint.ts' +import type { Peer } from './remote/peer.ts' +import { ensureTxs, prefetchTree } from './remote/sync.ts' +import { resolveOutpoint } from './resolver.ts' +import { + collectSnapshot, + type DirChild, + GIT_DIR, + readDir, + writeTree, +} from './tree.ts' +import type { TxStore } from './txstore.ts' + +export type Imported = { + /** The commit the root publishes. */ + tip: string + /** Commit objects written into git by this fetch. */ + commits: number + /** Ancestor trees written into git by this fetch. */ + trees: number +} + +/** Parent shas from a raw git commit object. */ +export function commitParents(commit: Uint8Array): string[] { + const text = new TextDecoder().decode(commit) + const header = text.split('\n\n', 1)[0] ?? '' + return header + .split('\n') + .filter((l) => l.startsWith('parent ')) + .map((l) => l.slice(7).trim()) +} + +/** Import everything a head publishes. */ +export async function importHead( + store: TxStore, + gitDir: string, + headOutpoint: string, + peer?: Peer, +): Promise { + const head = await readHead(store, headOutpoint) + return importRoot(store, gitDir, head.root, peer) +} + +/** + * Import a published root: the tip's tree, every commit object in `.git`, + * and every ancestor tree those commits need. git's own connectivity check + * walks commit to tree to blob, so a history missing one ancestor tree is + * a history git will refuse — all of it goes in. + */ +export async function importRoot( + store: TxStore, + gitDir: string, + root: Outpoint, + peer?: Peer, +): Promise { + await ensureTxs(store, peer, [root.txid]) + const entries = await readDir(store, root) + const gitEntry = entries.find((e) => e.name === GIT_DIR && e.isDir) + if (!gitEntry) { + throw new Error( + `published root ${formatOutpoint(root)} has no ${GIT_DIR} store`, + ) + } + await ensureTxs(store, peer, [gitEntry.outpoint.txid]) + const objects = await readDir(store, gitEntry.outpoint) + + const tipEntry = objects.find((e) => e.name === '.') + if (!tipEntry) { + throw new Error(`${GIT_DIR} names no tip commit (no "." entry)`) + } + const tipBytes = (await resolveOutpoint(store, tipEntry.outpoint)).bytes + const tip = gitHash('commit', tipBytes) + + // One request for everything this fetch still needs, rather than one + // per object: what git already has is skipped by name. + const wanted: DirChild[] = [] + for (const o of objects) { + if (o.name === '.') continue + if (await gitHasGitObject(gitDir, o.name)) continue + wanted.push(o) + } + await ensureTxs(store, peer, wanted.map((o) => o.outpoint.txid)) + + const names = new Set(objects.map((o) => o.name)) + let commits = 0 + let trees = 0 + for (const o of wanted) { + if (o.isDir) { + await importTree(store, gitDir, o.name, o.outpoint, peer) + trees++ + continue + } + const bytes = (await resolveOutpoint(store, o.outpoint)).bytes + const got = gitHash('commit', bytes) + if (got !== o.name) { + throw new Error(`${GIT_DIR}/${o.name} is a commit that hashes to ${got}`) + } + await writeGitObject(gitDir, 'commit', bytes) + commits++ + } + + // The tip's own tree is the root, minus the store. + await prefetchTree(store, peer, root) + const snapshot = await collectSnapshot(store, root) + const files = snapshot.files.filter( + (f) => f.path !== GIT_DIR && !f.path.startsWith(`${GIT_DIR}/`), + ) + const tipTree = await writeTree(gitDir, files) + const wantTree = treeShaFromCommit(tipBytes) + if (tipTree !== wantTree) { + throw new Error( + `published root ${formatOutpoint(root)} resolves to tree ${tipTree}, not ${wantTree} (the commit's own)`, + ) + } + await writeGitObject(gitDir, 'commit', tipBytes) + + // Every commit git now has must have its tree, or git will reject the + // history as incomplete. Say so here rather than leaving git to. + for (const o of objects) { + if (o.isDir || o.name === '.') continue + const bytes = (await resolveOutpoint(store, o.outpoint)).bytes + const need = treeShaFromCommit(bytes) + if (need === tipTree || names.has(need)) continue + if (await gitHasGitObject(gitDir, need)) continue + throw new Error( + `${GIT_DIR} has commit ${o.name} but not its tree ${need}: the history is incomplete`, + ) + } + return { tip, commits, trees } +} + +/** Write one ancestor tree, checking it is the tree its name claims. */ +async function importTree( + store: TxStore, + gitDir: string, + sha: string, + dir: Outpoint, + peer?: Peer, +): Promise { + await prefetchTree(store, peer, dir) + const files = (await collectSnapshot(store, dir)).files + const got = await writeTree(gitDir, files) + if (got !== sha) { + throw new Error(`${GIT_DIR}/${sha} is a tree that materialises as ${got}`) + } +} + +async function gitHasGitObject(gitDir: string, sha: string): Promise { + if (!/^[0-9a-f]{40}$/.test(sha)) return false + return gitHasObject(gitDir, sha, 'any') +} diff --git a/src/git-remote-gib.ts b/src/git-remote-gib.ts new file mode 100644 index 0000000..33f74b7 --- /dev/null +++ b/src/git-remote-gib.ts @@ -0,0 +1,47 @@ +#!/usr/bin/env bun +import { createInterface } from 'node:readline' +import { peerFetchRawTx } from './remote/store.ts' +import { peerFor } from './remote/peer.ts' +import { parseGibUrl } from './remote/url.ts' +import { runHelper } from './remote/helper.ts' +import { localBranches } from './gitread.ts' +import { defaultGibHome, fileTxStore } from './txstore.ts' +import { connectWallet } from './wallet.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] +if (!url) { + console.error('git-remote-gib: missing remote url') + process.exit(1) +} + +const gitDir = process.env.GIT_DIR ?? '.git' +const home = defaultGibHome() + +try { + const peer = await peerFor(parseGibUrl(url)) + const rl = createInterface({ input: process.stdin, terminal: false }) + const iter = rl[Symbol.asyncIterator]() + await runHelper({ + url, + store: fileTxStore(home, peerFetchRawTx(peer)), + peer, + wallet: async () => connectWallet(), + gitDir, + home, + localBranches: await localBranches(gitDir), + io: { + async read() { + const n = await iter.next() + return n.done ? null : String(n.value) + }, + write(s) { + process.stdout.write(s) + }, + }, + }) +} catch (e) { + console.error(`git-remote-gib: ${e instanceof Error ? e.message : e}`) + process.exit(1) +} diff --git a/src/git.ts b/src/git.ts new file mode 100644 index 0000000..e2a36ca --- /dev/null +++ b/src/git.ts @@ -0,0 +1,86 @@ +import { createHash } from 'node:crypto' +import { existsSync } from 'node:fs' +import { mkdir, rename, 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)) + // A loose object is content-addressed and written read-only. One that + // is already there is already right, and rewriting it fails on its own + // permissions. + if (existsSync(path)) return sha + 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 }) + const tmp = `${path}.${process.pid}.tmp` + await writeFile(tmp, deflateSync(raw), { mode: 0o444 }) + await rename(tmp, path) + 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' +} + +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) => + compareBytes(gitSortKey(a.mode, a.name), gitSortKey(b.mode, b.name)), + ) + 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..7c2cd24 --- /dev/null +++ b/src/gitread.ts @@ -0,0 +1,191 @@ +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() +} + +/** + * True when `anc` is an ancestor of `desc`. git exits 1 for "no" and 128 + * for "I have never heard of that commit" — which happens when the head + * the wallet holds publishes a commit this clone does not have. Reporting + * that as "not an ancestor" would tell the user their push is a + * non-fast-forward when it is nothing of the kind. + */ +export async function isAncestor(gitDir: string, anc: string, desc: string): Promise { + const r = await git(gitDir, ['merge-base', '--is-ancestor', anc, desc]) + if (r.code === 0) return true + if (r.code === 1) return false + throw new Error( + `this clone does not have commit ${anc}, which the branch's current head publishes: ${r.err.trim()}`, + ) +} + +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 === 'commit') { + // A gitlink has no bytes to publish. Saying so here beats the + // tree-mismatch error the push would otherwise die of, which + // names neither submodules nor the path. + throw new Error( + `${path} is a submodule (gitlink); gib cannot publish submodules`, + ) + } + 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 === '.gib' || path.endsWith('/.gib')) return 'application/json' + 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 === '' } +} + +/** + * Commits to publish: everything reachable from `tip` that is not already + * reachable from a commit some head already publishes, oldest first. That + * ordering is the order the heads are minted in, so the spend chain and + * the commit history run the same way. + */ +export async function revList( + gitDir: string, + tip: string, + have: string[] = [], + limit = 100_000, +): Promise { + const args = ['rev-list', '--reverse', '--topo-order', `--max-count=${limit}`, tip] + for (const h of have) args.push(`^${h}`) + const r = await git(gitDir, args) + if (r.code !== 0) throw new Error(`git rev-list ${tip}: ${r.err.trim()}`) + return r.out.split('\n').map((l) => l.trim()).filter(Boolean) +} + +/** True when git has the object, of that type (or any type). */ +export async function hasObject( + gitDir: string, + sha: string, + type: 'commit' | 'any' = 'commit', +): Promise { + const rev = type === 'commit' ? `${sha}^{commit}` : sha + return (await git(gitDir, ['cat-file', '-e', rev])).code === 0 +} + +/** + * Every commit reachable from `tip`, oldest first, with the sha of the + * tree it names. One call, because a repository's whole history is asked + * for on every push. + */ +export async function commitTreePairs( + gitDir: string, + tip: string, + limit = 200_000, +): Promise> { + const r = await git(gitDir, [ + 'log', + '--reverse', + '--topo-order', + `--max-count=${limit}`, + '--format=%H %T', + tip, + ]) + if (r.code !== 0) throw new Error(`git log ${tip}: ${r.err.trim()}`) + return r.out + .split('\n') + .map((l) => l.trim()) + .filter(Boolean) + .map((l) => { + const [sha, tree] = l.split(' ') + return { sha, tree } + }) +} + +/** Branch names the local repository has, for a first refresh from a peer. */ +export async function localBranches(gitDir: string): Promise { + const r = await git(gitDir, [ + 'for-each-ref', + '--format=%(refname:short)', + 'refs/heads', + ]) + if (r.code !== 0) return [] + return r.out.split('\n').map((l) => l.trim()).filter(Boolean) +} diff --git a/src/head.ts b/src/head.ts new file mode 100644 index 0000000..825cef8 --- /dev/null +++ b/src/head.ts @@ -0,0 +1,101 @@ +/** + * Reading commit heads. + * + * A head is a bare 1-satoshi PushDrop naming a repository origin, a + * branch, a published root and its publisher, and spending the branch's + * previous head. Nothing is inscribed on it: the commit it publishes is + * the tip commit object inside the root's `.git` store, which the `.` + * default entry points at. + * + * So reading a head's *token* costs nothing but the head transaction, + * while reading the commit it publishes costs the root and `.git` as well. + * Keep the two apart: `list` only needs the sha of a branch's newest head, + * not of every head on its chain. + */ + +import { gitHash } from './git.ts' +import { formatOutpoint, type Outpoint, parseOutpoint } from './outpoint.ts' +import { loadTx, resolvePath } from './resolver.ts' +import { type CommitToken, decodeCommitToken } from './token.ts' +import { GIT_DIR } from './tree.ts' +import type { TxStore } from './txstore.ts' + +export type Head = { + outpoint: string + token: CommitToken + root: Outpoint +} + +/** The head at an outpoint, or a throw when it is not one. */ +export async function readHead( + store: TxStore, + outpoint: string, +): Promise { + const op = parseOutpoint(outpoint) + const tx = await loadTx(store, op.txid) + const out = tx.outputs[op.vout] + if (!out) throw new Error(`missing head ${outpoint}`) + const token = decodeCommitToken(out.lockingScript) + return { + outpoint: formatOutpoint(op, '_'), + token, + root: parseOutpoint(token.root), + } +} + +/** + * The tip commit object of a published root: the `.` default entry of its + * `.git` store. Needs the root's content, not just the head. + */ +export async function tipCommit( + store: TxStore, + root: Outpoint, +): Promise { + const resolved = await resolvePath(store, root, GIT_DIR) + return resolved.bytes +} + +/** The commit sha a published root's tip commit object hashes to. */ +export async function tipSha( + store: TxStore, + root: Outpoint, +): Promise { + return gitHash('commit', await tipCommit(store, root)) +} + +/** + * The head this head spent — the branch's previous push — or undefined for + * a first head, or when the spent transaction is not held locally. + */ +export async function previousHead( + store: TxStore, + outpoint: string, +): Promise { + const op = parseOutpoint(outpoint) + const tx = await loadTx(store, op.txid) + const self = tx.outputs[op.vout] + if (!self) throw new Error(`missing head ${outpoint}`) + let token: CommitToken | undefined + try { + token = decodeCommitToken(self.lockingScript) + } catch { + token = undefined + } + for (const input of tx.inputs) { + const src = input.sourceTXID + if (!src) continue + try { + const sourceTx = await loadTx(store, src) + const out = sourceTx.outputs[input.sourceOutputIndex] + if (!out || out.satoshis !== 1) continue + const prev = decodeCommitToken(out.lockingScript) + if (token && (prev.origin !== token.origin || prev.branch !== token.branch)) { + continue + } + return `${src.toLowerCase()}_${input.sourceOutputIndex}` + } catch { + // not a commit head, or its transaction is not here + } + } + return undefined +} diff --git a/src/identity.ts b/src/identity.ts new file mode 100644 index 0000000..62f23e4 --- /dev/null +++ b/src/identity.ts @@ -0,0 +1,35 @@ +/** + * The wallet's identity key, cached beside the store. + * + * `list`, `fetch` and `clone` never need a wallet, but they do need to + * know which heads are the user's own — those advertise as plain + * refs/heads/, everyone else's as refs/heads/@/. + * With no wallet and no cache, nothing is bare. + */ + +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { parseIdentity } from './remote/url.ts' +import { defaultGibHome } from './txstore.ts' + +const FILE = 'identity' + +export async function loadIdentity(home?: string): Promise { + try { + const raw = await readFile(join(home ?? defaultGibHome(), FILE), 'utf8') + return parseIdentity(raw) ?? '' + } catch { + return '' + } +} + +export async function saveIdentity( + identity: string, + home?: string, +): Promise { + const id = parseIdentity(identity) + if (!id) throw new Error(`not an identity key: ${identity}`) + const dir = home ?? defaultGibHome() + await mkdir(dir, { recursive: true }) + await writeFile(join(dir, FILE), `${id}\n`) +} diff --git a/src/init.ts b/src/init.ts new file mode 100644 index 0000000..e00a77d --- /dev/null +++ b/src/init.ts @@ -0,0 +1,223 @@ +/** + * `gib init` — create a repository on chain. + * + * This is where a repository is born: the commit HEAD points at is + * published, and the root of its tree becomes the repository origin, the + * outpoint that names the repository for ever after. Only the wallet and + * the local store are involved; no peer hears about it until a push. + * + * A push never mints a repository, so there is exactly one way to create + * one and no way to create a second by accident. + */ + +import { existsSync } from 'node:fs' +import { readFile, writeFile } from 'node:fs/promises' +import { basename, join, resolve } from 'node:path' +import type { WalletInterface } from '@bsv/sdk' +import { saveIdentity } from './identity.ts' +import { mintGenesis } from './push.ts' +import { type Publisher, walletPublisher } from './publish.ts' +import { formatRepoMeta, GIB_FILE, parseRepoMeta, type RepoMeta } from './repo-meta.ts' +import { emptyRepoState, loadRepoState, recordHead, saveRepoState } from './refs.ts' +import { readHead } from './head.ts' +import { parseGibUrl } from './remote/url.ts' +import type { TxStore } from './txstore.ts' + +/** The host `gib init` suggests publishing through. */ +export const DEFAULT_PEER_HOST = 'gibhub.net' + +export type InitOptions = { + cwd: string + wallet: WalletInterface + store: TxStore + publisher?: Publisher + home?: string + name?: string + description?: string + /** Name of the local-only remote to add; defaults to `local`. */ + remote?: string + /** Peer host to suggest for publishing. */ + host?: string + /** Called with computed defaults when interactive. */ + prompt?: (defaults: RepoMeta) => Promise + log?: (s: string) => void +} + +export type InitResult = { + root: string + file: string + meta: RepoMeta + /** Repository origin: the genesis root outpoint. */ + origin: string + branch: string + sha: string + head: string + identity: string + /** False when the repository already had a gib remote. */ + created: boolean + remote: string + remoteUrl: string + remoteAction: 'added' | 'unchanged' + /** The remote to add to publish through a peer. */ + peerUrl: string + wroteMeta: 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 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' +} + +/** The repository origin an existing gib:// remote already names, if any. */ +async function existingOrigin(root: string): Promise<{ remote: string; origin: string } | undefined> { + const r = await git(root, ['remote', '-v']) + if (r.code !== 0) return undefined + for (const line of r.out.split('\n')) { + const [name, url] = line.split(/\s+/) + if (!url?.startsWith('gib://')) continue + try { + return { remote: name, origin: parseGibUrl(url).origin } + } catch { + // a malformed gib remote is not an existing repository + } + } + return undefined +} + +export async function gibInit(opts: InitOptions): Promise { + const top = await git(opts.cwd, ['rev-parse', '--show-toplevel']) + if (top.code !== 0) { + throw new Error('not a git repository: run `git init`, commit, then `gib init`') + } + const root = resolve(top.out) + const gitDirOut = await git(root, ['rev-parse', '--absolute-git-dir']) + if (gitDirOut.code !== 0) throw new Error(`git dir: ${gitDirOut.err}`) + const gitDir = gitDirOut.out + if ((await git(root, ['rev-parse', '--verify', '-q', 'HEAD^{commit}'])).code !== 0) { + throw new Error('gib init needs at least one commit') + } + const remote = opts.remote ?? 'local' + const host = opts.host ?? DEFAULT_PEER_HOST + const branch = await currentBranch(root) + + // `.gib` is written when missing and left alone otherwise. defaultBranch + // is kept because it is the only hint a clone has about which branch to + // ask a peer for: no lookup enumerates a repository's branches. + const file = join(root, GIB_FILE) + let meta: RepoMeta + let wroteMeta = false + if (existsSync(file)) { + meta = parseRepoMeta(await readFile(file, 'utf8')) + } else { + const defaults: RepoMeta = { + name: opts.name ?? basename(root), + description: opts.description, + defaultBranch: branch, + } + const chosen = opts.prompt ? await opts.prompt(defaults) : defaults + meta = { + name: (chosen.name ?? defaults.name)?.trim() || defaults.name, + description: chosen.description?.trim() || undefined, + defaultBranch: + (chosen.defaultBranch ?? defaults.defaultBranch)?.trim() || branch, + } + parseRepoMeta(formatRepoMeta(meta)) + await writeFile(file, formatRepoMeta(meta)) + wroteMeta = true + } + + const { publicKey: identity } = await opts.wallet.getPublicKey({ + identityKey: true, + }) + await saveIdentity(identity, opts.home).catch(() => {}) + + const already = await existingOrigin(root) + if (already) { + const state = await loadRepoState(already.origin, opts.home) + const ref = Object.values(state.refs).find((r) => r.identity === identity) + return { + root, + file, + meta, + origin: already.origin, + branch: state.genesis?.branch ?? branch, + sha: ref?.sha ?? '', + head: ref?.head ?? '', + identity, + created: false, + remote: already.remote, + remoteUrl: `gib://${already.origin}`, + remoteAction: 'unchanged', + peerUrl: `gib://${host}/${already.origin}`, + wroteMeta, + } + } + + if ((await git(root, ['cat-file', '-e', `HEAD:${GIB_FILE}`])).code !== 0) { + opts.log?.( + `note: ${GIB_FILE} is not committed; the genesis tree will not carry it (commit it and push to publish it)\n`, + ) + } + + const minted = await mintGenesis({ + gitDir, + store: opts.store, + wallet: opts.wallet, + publisher: opts.publisher ?? walletPublisher(opts.wallet), + identity, + home: opts.home, + rev: 'HEAD', + branch, + log: opts.log, + }) + + const state = emptyRepoState(minted.origin) + const head = await readHead(opts.store, minted.head) + recordHead(state, { + identity, + branch, + head: minted.head, + sha: minted.sha, + root: head.token.root, + }) + await saveRepoState(state, opts.home) + + const localUrl = `gib://${minted.origin}` + const existing = await git(root, ['remote', 'get-url', remote]) + let remoteAction: InitResult['remoteAction'] = 'unchanged' + if (existing.code !== 0) { + const add = await git(root, ['remote', 'add', remote, localUrl]) + if (add.code !== 0) throw new Error(`git remote add ${remote}: ${add.err}`) + remoteAction = 'added' + } + return { + root, + file, + meta, + origin: minted.origin, + branch, + sha: minted.sha, + head: minted.head, + identity, + created: true, + remote, + remoteUrl: existing.code === 0 ? existing.out : localUrl, + remoteAction, + peerUrl: `gib://${host}/${minted.origin}`, + wroteMeta, + } +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..f2b1632 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,161 @@ +#!/usr/bin/env bun +import { createInterface } from 'node:readline/promises' +import { DEFAULT_PEER_HOST, gibInit } from './init.ts' +import { loadIdentity } from './identity.ts' +import { loadRepoState, saveRepoState } from './refs.ts' +import { GIB_FILE, type RepoMeta } from './repo-meta.ts' +import { peerFor } from './remote/peer.ts' +import { parseGibUrl } from './remote/url.ts' +import { pullRepo } from './remote/sync.ts' +import { peerFetchRawTx } from './remote/store.ts' +import { defaultGibHome, fileTxStore, txidOf } from './txstore.ts' +import { connectWallet, walletUrl } from './wallet.ts' + +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) +const home = defaultGibHome() + +if (cmd === 'help' || cmd === '-h' || cmd === '--help') { + process.stdout.write( + 'gib — on-chain git\n' + + ' git-remote-gib is the helper; add/commit/status stay git.\n' + + '\n' + + ' gib init [-y] [--name n] [--description d] [--remote local] [--host gibhub.net]\n' + + ` mint the repository from HEAD, write ${GIB_FILE}, add the local remote\n` + + ' gib sync [branch...]\n' + + ' refresh a repository from its peer; naming a branch\n' + + ' teaches this client a branch it could not discover\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 wallet = connectWallet() + const r = await gibInit({ + cwd: process.cwd(), + wallet, + store: fileTxStore(home), + home, + name: flag('--name'), + description: flag('--description'), + remote: flag('--remote'), + host: flag('--host') ?? DEFAULT_PEER_HOST, + log: (s) => process.stderr.write(s), + 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: d.defaultBranch, + } + rl.close() + return meta + } + : undefined, + }) + if (r.wroteMeta) process.stdout.write(`wrote ${r.file}\n`) + if (r.created) { + process.stdout.write( + `minted repository origin ${r.origin}\n branch ${r.branch} at ${r.sha}\n identity ${r.identity}\n`, + ) + } else { + process.stdout.write( + `repository origin ${r.origin} already publishes this repository\n`, + ) + } + process.stdout.write( + r.remoteAction === 'added' + ? `remote '${r.remote}' -> ${r.remoteUrl} (local only)\n` + : `remote '${r.remote}' -> ${r.remoteUrl}\n`, + ) + process.stdout.write( + `publish through a peer:\n git remote add gib ${r.peerUrl}\n git push gib ${r.branch}\n`, + ) + } catch (e) { + console.error(`gib init: ${e instanceof Error ? e.message : e}`) + process.exit(1) + } + process.exit(0) +} + +if (cmd === 'sync') { + const url = argv[1] + if (!url) { + console.error( + 'usage: gib sync gib:/// [branch...]', + ) + process.exit(1) + } + // Naming branches is how a client learns of one it cannot discover: + // the lookup service has no query that enumerates them. + const branches = argv.slice(2).filter((a) => !a.startsWith('-')) + try { + const parsed = parseGibUrl(url) + const peer = await peerFor(parsed) + if (!peer) throw new Error('that URL names no peer to sync with') + const store = fileTxStore(home, peerFetchRawTx(peer)) + const state = await loadRepoState(parsed.origin, home) + const added = await pullRepo(peer, store, state, branches) + await saveRepoState(state, home) + for (const w of state.warnings) process.stderr.write(`gib: ${w}\n`) + process.stdout.write(`${added} new head(s)\n`) + for (const r of Object.values(state.refs)) { + process.stdout.write(`${r.sha} ${r.identity.slice(0, 8)}… ${r.branch}\n`) + } + } catch (e) { + console.error(`gib sync: ${e instanceof Error ? e.message : e}`) + process.exit(1) + } + process.exit(0) +} + +if (cmd === 'doctor') { + process.stdout.write(`GIB_HOME=${home}\n`) + process.stdout.write(`wallet=${walletUrl()}\n`) + const cached = await loadIdentity(home) + process.stdout.write(`identity=${cached || '(not cached)'}\n`) + try { + const w = connectWallet() + const { publicKey } = await w.getPublicKey({ identityKey: true }) + process.stdout.write(`wallet: ok (${publicKey})\n`) + } catch (e) { + process.stdout.write(`wallet: ${e instanceof Error ? e.message : e}\n`) + } + process.exit(0) +} + +if (cmd === 'put') { + const file = argv[1] + 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(home).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..ca64fe2 --- /dev/null +++ b/src/ordfs/dir.ts @@ -0,0 +1,310 @@ +/** + * `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 + * (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' + +/** 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 }) + +/** 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 { + 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 +} + +/** + * 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') + 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({ + 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/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 new file mode 100644 index 0000000..c09fb55 --- /dev/null +++ b/src/ordfs/patch.ts @@ -0,0 +1,137 @@ +import { outpointFromWire, outpointToWire } from './outpoint.js' +import { + VcdiffError, + assertPlainRfc, + 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' + } +} + +/** Display-hex txid + vout; wire form is internal-order txid + LE vout. */ +export interface PatchOutpoint { + txid: string + vout: number +} + +export interface PatchRecord { + version: number + base: PatchOutpoint + delta: Uint8Array +} + +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`) + } + try { + assertPlainRfc(record.delta) + } catch (err) { + throw new PatchFormatError( + err instanceof Error ? err.message : 'invalid vcdiff delta', + ) + } + 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) + out[0] = record.version + out.set(wire, 1) + 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]}`) + } + 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, + 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..caa3846 --- /dev/null +++ b/src/ordfs/vcdiff.ts @@ -0,0 +1,106 @@ +/** + * RFC 3284 VCDIFF encode/decode via xdelta3-wasm. + * + * 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). + * Readers apply any delta the wasm decoder can handle. + */ + +export class VcdiffError extends Error { + constructor(message: string) { + super(message) + this.name = 'VcdiffError' + } +} + +type Xd3 = typeof import('xdelta3-wasm') + +let lib: Xd3 | undefined +let ready: Promise | undefined + +export async function vcdiffReady(): Promise { + if (!ready) { + ready = (async () => { + lib = await import('xdelta3-wasm') + await lib.init() + })() + } + await ready +} + +export 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)`, + ) + } +} + +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) + if (r.ret === 0) { + assertPlainRfc(r.output) + return r.output + } + 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') +} + +export async function vcdiffDecode( + delta: Uint8Array, + source: Uint8Array = new Uint8Array(0), +): 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 === 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/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..180e376 --- /dev/null +++ b/src/pending.ts @@ -0,0 +1,46 @@ +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { defaultGibHome } from './txstore.ts' + +/** + * A transaction an interrupted push already published. Kept so a retry + * reuses it instead of paying to publish the same content twice. + */ +export type PendingTx = { txid: string; bytes: Uint8Array; beef: number[] } + +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) => ({ + txid: t.txid, + hex: Buffer.from(t.bytes).toString('hex'), + beef: Buffer.from(new Uint8Array(t.beef)).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<{ + txid: string + hex: string + beef?: string + }> + return raw.map((t) => ({ + txid: t.txid, + bytes: new Uint8Array(Buffer.from(t.hex, 'hex')), + beef: Array.from(Buffer.from(t.beef ?? '', '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 }) +} diff --git a/src/preview.ts b/src/preview.ts new file mode 100644 index 0000000..0767650 --- /dev/null +++ b/src/preview.ts @@ -0,0 +1,49 @@ +/** + * The dry run. + * + * Before a push spends anything it plans the whole tree, packs it into + * transactions that are built but never funded or broadcast, and resolves + * the result back out of a scratch store with the real reader. What that + * checks is the thing that matters: that the published root, with `.git` + * stripped, is the tree git hashed. + * + * The transaction ids differ from the ones the wallet will produce, so + * this proves the shape, not the bytes. The bytes are checked against the + * plan when the wallet hands the real transaction back (see packContent). + */ + +import { Transaction } from '@bsv/sdk' +import type { PlannedOutput, PublishedTx } from './publish.ts' +import { bLockingScript } from './script.ts' +import type { TxStore } from './txstore.ts' + +/** A store that reads through to `backing` and keeps its writes in memory. */ +export function overlayStore(backing: TxStore): TxStore { + const mem = new Map() + return { + async get(txid) { + return mem.get(txid.toLowerCase()) ?? (await backing.get(txid)) + }, + async put(txid, bytes) { + mem.set(txid.toLowerCase(), bytes) + }, + } +} + +/** Build the transaction a publisher would, without funding or signing it. */ +export async function dryPublish( + outputs: PlannedOutput[], +): Promise { + const tx = new Transaction() + for (const o of outputs) { + tx.addOutput({ + satoshis: 0, + lockingScript: bLockingScript(o.contentType, o.bytes), + }) + } + return { + txid: tx.id('hex'), + bytes: new Uint8Array(tx.toBinary()), + beef: [], + } +} diff --git a/src/publish.ts b/src/publish.ts new file mode 100644 index 0000000..67f3ddb --- /dev/null +++ b/src/publish.ts @@ -0,0 +1,257 @@ +import { + completeSignedAction, + stampManagedOutputIds, + unlockByScript, +} from '@1sat/actions' +import { + Beef, + type CreateActionArgs, + type CreateActionResult, + Transaction, + type WalletInterface, +} from '@bsv/sdk' +/** One output a content transaction will carry. */ +export type PlannedOutput = { + contentType: string + bytes: Uint8Array + /** What it is, for the wallet's output description. */ + path?: string +} +import { bLockingScript, isProvablyUnspendable } from './script.ts' +import { commitHeadCustomInstructions, sealCommitLock } from './seal.ts' +import { + GIB_BASKET, + GIB_PROTOCOL, + branchTag, + commitTag, + originTag, + pushDescription, + type CommitToken, +} from './token.ts' + +export type PublishedTx = { + txid: string + /** Raw signed transaction bytes, for the local store. */ + bytes: Uint8Array + /** BEEF for the transaction with its ancestry, for the peer. */ + beef: number[] +} + +/** A published head, and which output of its transaction it is. */ +export type PublishedHead = PublishedTx & { vout: number } + +export type SpendHead = { + outpoint: string + beef: number[] + keyID: string +} + +export type Publisher = { + publishContent( + outputs: PlannedOutput[], + labels: string[], + sha: string, + ): Promise + publishHead(opts: { + token: CommitToken + sha: string + labels: string[] + tags: 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') + return published(r.txid, Array.from(r.tx)) +} + +/** + * The wallet hands back BEEF (atomic or not). Keep both forms: raw bytes + * for the local store, and a plain BEEF with the ancestry for the peer and + * for spending the output later. + */ +export function published(txid: string, beef: number[]): PublishedTx { + const parsed = Beef.fromBinary(beef) + const tx = parsed.findAtomicTransaction(txid) ?? parsed.findTxid(txid)?.tx + if (!tx) throw new Error(`wallet BEEF does not contain ${txid}`) + const plain = new Beef() + plain.mergeBeef(parsed) + return { + txid, + bytes: new Uint8Array(tx.toBinary()), + beef: plain.toBinary(), + } +} + +/** Which output of a transaction carries a locking script. */ +function voutOfScript(bytes: Uint8Array, hex: string): number { + const tx = Transaction.fromBinary(Array.from(bytes)) + const vout = tx.outputs.findIndex((o) => o.lockingScript.toHex() === hex) + if (vout < 0) throw new Error('published transaction has no commit head') + return vout +} + +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 r = await unlockByScript( + wallet, + tx, + idx, + src.lockingScript, + src.satoshis ?? 1, + { protocolID: GIB_PROTOCOL, keyID, counterparty: 'anyone' }, + ) + if ('error' in r) throw new Error(`unlock head: ${r.error}`) + return { [idx]: { unlockingScript: r.unlockingScript } } + }, + { acceptDelayedBroadcast: false }, + ) + if (done.error || !done.txid || !done.tx) { + throw new Error(done.error ?? 'signAction returned no tx') + } + return published(done.txid, Array.from(done.tx)) +} + +/** + * Wallet substrates (HTTPWalletJSON) throw the whole request in the error + * text, which for a content push is the entire tree as hex; git's packet + * line then truncates the message before the reason. Keep call + message. + */ +async function walletCall(what: string, run: () => Promise): Promise { + try { + return await run() + } catch (e) { + const text = e instanceof Error ? e.message : String(e) + if (text.startsWith('{')) { + try { + const j = JSON.parse(text) as { call?: string; message?: string } + if (j.message) throw new Error(`wallet ${j.call ?? what}: ${j.message}`) + } catch (inner) { + if (inner instanceof Error && inner.message.startsWith('wallet ')) throw inner + } + } + throw new Error(`wallet ${what}: ${text.length > 500 ? `${text.slice(0, 500)}…` : text}`) + } +} + +export function walletPublisher(wallet: WalletInterface): Publisher { + return { + async publishContent(planned, labels, sha) { + const outputs = planned.map((o, i) => { + const script = bLockingScript(o.contentType, o.bytes) + if (!isProvablyUnspendable(script)) { + throw new Error(`refusing to publish a zero-sat output miners would treat as dust: ${o.path ?? i}`) + } + return { + lockingScript: script.toHex(), + satoshis: 0, + // BRC-100 wallets require 5-50 chars here; paths like "/" are shorter. + outputDescription: `gib ${o.path ?? `content ${i}`}`.slice(0, 50), + } + }) + const r = await walletCall('createAction (content)', () => + wallet.createAction({ + description: pushDescription('content', sha), + outputs, + labels, + // signAndProcess defaults to true; setting it explicitly is admin-only in some wallets. + options: { randomizeOutputs: false }, + }), + ) + return rawTxFromResult(r) + }, + async publishHead(opts) { + if (opts.spend && !opts.spend.keyID) { + throw new Error('spend missing customInstructions keyID') + } + // A bare PushDrop: the commit object lives in the tree's `.git` + // store like every other commit, so there is nothing to inscribe + // beside the token. + const lockingHex = (await sealCommitLock(wallet, opts.token)).toHex() + const args: CreateActionArgs = { + description: pushDescription('head', opts.sha), + ...(opts.spend ? { inputBEEF: opts.spend.beef } : {}), + inputs: opts.spend + ? [ + { + outpoint: opts.spend.outpoint, + inputDescription: 'gib commit token', + unlockingScriptLength: 73, + }, + ] + : undefined, + outputs: [ + { + lockingScript: lockingHex, + satoshis: 1, + outputDescription: 'gib commit head', + basket: GIB_BASKET, + tags: opts.tags, + customInstructions: commitHeadCustomInstructions(opts.token.root), + }, + ], + labels: opts.labels, + options: opts.spend + ? { randomizeOutputs: false, signAndProcess: false } + : { randomizeOutputs: false }, + } + stampManagedOutputIds(args) + const r = await walletCall('createAction (head)', () => wallet.createAction(args)) + const head = r.txid + ? rawTxFromResult(r) + : opts.spend + ? await unlockPushDrop( + wallet, + opts.spend.keyID, + opts.spend.outpoint, + opts.spend.beef, + r, + ) + : (() => { + throw new Error('unexpected createAction response for commit head') + })() + return { ...head, vout: voutOfScript(head.bytes, lockingHex) } + }, + async burnHead(opts) { + const r = await walletCall('createAction (burn)', () => + wallet.createAction({ + description: 'gib burn ref', + inputBEEF: opts.beef, + inputs: [ + { + outpoint: opts.outpoint, + inputDescription: 'gib commit token burn', + unlockingScriptLength: 73, + }, + ], + labels: opts.labels, + options: { signAndProcess: false }, + }), + ) + return unlockPushDrop(wallet, opts.keyID, opts.outpoint, opts.beef, r) + }, + } +} + +export function headTags(origin: string, branch: string, sha: string): string[] { + return [originTag(origin), branchTag(branch), commitTag(sha)] +} diff --git a/src/push.ts b/src/push.ts new file mode 100644 index 0000000..4838e4e --- /dev/null +++ b/src/push.ts @@ -0,0 +1,640 @@ +/** + * `git push` for gib. + * + * One head per push. A push mints a single head token, spending the + * branch's previous head, pointing at one published root: git's tree for + * the tip commit, plus a `.git` store holding every commit object + * reachable from that tip and every one of those commits' trees. Commits + * are hash-linked, so a signature over the tip commits to every ancestor — + * a head per commit bought nothing and cost a transaction each. + * + * Because the store is keyed by sha, a commit or a tree that is already on + * chain is cited at the outpoint that holds it. Branching from someone + * else's head therefore copies nothing: their objects are already + * published, and this push's `.git` points at them. + * + * Pushing never mints a repository: `gib init` creates one (mintGenesis) + * and a push joins the repository the remote URL names. + */ + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Beef, LockingScript, Transaction, type WalletInterface } from '@bsv/sdk' +import { + loadPublishedRoot, + type Plan, + type PlanEntry, + type PlanRef, + planCommit, + type Tree, +} from './cascade.ts' +import { Plan as PlanBuilder } from './cascade.ts' +import { packContent } from './chain.ts' +import { commitParents, importRoot } from './fetch.ts' +import { previousHead, tipSha } from './head.ts' +import { + commitBytes, + commitTreePairs, + filesAtCommit, + isAncestor, + parsePushLine, + revParse, +} from './gitread.ts' +import { formatOutpoint, type Outpoint, parseOutpoint } from './outpoint.ts' +import { clearPending, loadPending, savePending } from './pending.ts' +import { dryPublish, overlayStore } from './preview.ts' +import { + headTags, + type Publisher, + type PublishedTx, + type SpendHead, +} from './publish.ts' +import { recoverPush } from './recovery.ts' +import { atomicWithExtras } from './remote/beef.ts' +import { MAX_PAGES, type Peer } from './remote/peer.ts' +import { GIT_COMMIT_TYPE } from './script.ts' +import { + branchTag, + decodeCommitToken, + GIB_BASKET, + gibKeyId, + LABEL_DELETE, + LABEL_PUSH, + NULL_SHA, + originTag, +} from './token.ts' +import { collectTxids, GIT_DIR } from './tree.ts' +import type { TxStore } from './txstore.ts' + +/** A head this client already knows about, for finding what to branch from. */ +export type KnownHead = { + outpoint: string + /** Commit it publishes, when this client has read it. */ + sha: string + identity: string + branch: string + root?: string +} + +export type PushOptions = { + gitDir: string + store: TxStore + wallet: WalletInterface + publisher: Publisher + /** Repository origin the remote names. */ + origin: string + /** The wallet's identity key. */ + identity: string + /** Peer to submit to, when the remote names one. */ + peer?: Peer + /** Heads this client knows of, to branch from and to merge in. */ + knownHeads?: KnownHead[] + home?: string + log?: (s: string) => void +} + +export type PushResult = + | { + ok: true + dst: string + branch: string + sha: string + /** Outpoint of the branch's new head ("" for a delete). */ + head: string + /** True when this push minted a head. */ + minted: boolean + /** The head this one branched from or merged in, if any. */ + branchedFrom: string + } + | { ok: false; dst: string; error: string } + +/** The branch a destination ref names. Another publisher's is refused. */ +export function branchFromRef(dst: string): string { + const name = dst.replace(/^refs\/heads\//, '') + if (!name || name === dst) { + throw new Error(`bad ref ${dst}: gib publishes refs/heads/ only`) + } + if (name.startsWith('@')) { + throw new Error( + `cannot push ${dst}: that is another publisher's branch; push your own with refs/heads/`, + ) + } + return name +} + +export async function pushLine( + line: string, + opts: PushOptions, +): Promise { + const spec = parsePushLine(line) + let branch: string + try { + branch = branchFromRef(spec.dst) + } catch (e) { + return { ok: false, dst: spec.dst, error: message(e) } + } + try { + if (!opts.origin) { + throw new Error( + 'push needs a repository origin: gib:///', + ) + } + if (spec.del) return await burnRef(opts, spec.dst, branch) + const sha = await revParse(opts.gitDir, spec.src) + const prev = await currentHead(opts, branch) + const known = opts.knownHeads?.find( + (h) => h.identity === opts.identity && h.branch === branch, + ) + if (!prev && known) { + // Minting here would start a second chain for this branch under + // the same identity, with nothing spending the existing head: + // two tips, no ancestry, and no way for a reader to tell which + // is the branch. The wallet has to be repaired first. + throw new Error( + `the wallet holds no spendable head for ${branch}, but ${known.outpoint} is its current head: pushing now would start a second chain`, + ) + } + if (prev?.sha === sha) { + // Already published under this identity — a retry, or a push to + // a second peer. Nothing is built; the peer catches up. + await syncPeer(opts, branch, prev.outpoint) + return { + ok: true, + dst: spec.dst, + branch, + sha, + head: prev.outpoint, + minted: false, + branchedFrom: '', + } + } + if (prev?.sha && !spec.force && !(await isAncestor(opts.gitDir, prev.sha, sha))) { + return { ok: false, dst: spec.dst, error: 'non-fast-forward' } + } + const r = await publishPush({ ...opts, sha, branch, prev }) + return { + ok: true, + dst: spec.dst, + branch, + sha, + head: r.head, + minted: true, + branchedFrom: r.branchedFrom, + } + } catch (e) { + return { ok: false, dst: spec.dst, error: message(e) } + } +} + +export type MintResult = { + /** Repository origin: newly minted for a genesis. */ + origin: string + head: string + sha: string + branchedFrom: string +} + +/** + * `gib init`: mint a repository. The published root of this first push + * becomes the repository origin, and its head is the repository's genesis + * head. Nothing is published to a peer here. + */ +export async function mintGenesis( + opts: Omit & { rev: string; branch: string }, +): Promise { + const sha = await revParse(opts.gitDir, opts.rev) + return publishPush({ ...opts, origin: '', sha, branch: opts.branch }) +} + +type PushState = PushOptions & { + sha: string + branch: string + prev?: HeadState +} + +async function publishPush(opts: PushState): Promise { + await abortStaleActions(opts.wallet, opts.sha, opts.log) + + // What this push continues from: our own head on this branch, or — for + // a branch's first push — the head it forks from. + const fork = opts.prev ? undefined : await pickFork(opts) + const baseRoot = opts.prev ? parseOutpoint(opts.prev.root) : fork?.root + const base = baseRoot ? await loadPublishedRoot(opts.store, baseRoot) : undefined + + const tip = await commitBytes(opts.gitDir, opts.sha) + const reachable = await commitTreePairs(opts.gitDir, opts.sha) + if (reachable.length === 0 || reachable[reachable.length - 1].sha !== opts.sha) { + throw new Error(`nothing to publish for ${opts.sha}`) + } + + const plan = new PlanBuilder() + // `.git` is an object store: a name is a sha, so a name is proof of + // content. Anything already on chain is cited, never republished. + const objects = new Map() + const cite = (name: string, isDir: boolean): boolean => { + const known = base?.objects.get(name) + if (!known) return false + objects.set(name, { name, isDir, ref: known.ref }) + return true + } + + let tree: Tree | undefined = base?.tree + let tipRootId: number | undefined + let tipCommitRef: PlanRef | undefined + let published = 0 + for (const { sha, tree: treeSha } of reachable) { + const isTip = sha === opts.sha + if (!cite(sha, false)) { + const bytes = isTip ? tip : await commitBytes(opts.gitDir, sha) + const id = plan.add({ + kind: 'data', + contentType: GIT_COMMIT_TYPE, + bytes, + label: `commit ${sha.slice(0, 12)}`, + }) + objects.set(sha, { name: sha, isDir: false, ref: { kind: 'node', id } }) + } + if (isTip) tipCommitRef = objects.get(sha)?.ref + + // A commit's tree is published once, under its own sha: two commits + // with the same tree share it, and an ancestor already on chain is + // cited whole. + const haveTree = objects.has(treeSha) || cite(treeSha, true) + if (!haveTree || isTip) { + const commitPlan = await planCommit({ + files: await filesAtCommit(opts.gitDir, sha), + prev: tree, + plan, + }) + tree = commitPlan.tree + published++ + if (!haveTree) { + objects.set(treeSha, { + name: treeSha, + isDir: true, + ref: { kind: 'node', id: commitPlan.rootId }, + }) + } + if (isTip) tipRootId = commitPlan.rootId + } + } + if (tipRootId === undefined || !tipCommitRef) { + throw new Error('push: the tip commit was not planned') + } + // The default entry is how a reader finds which commit a head + // publishes without reading every object in the store. + objects.set('.', { name: '.', isDir: false, ref: tipCommitRef }) + + const gitStoreId = plan.add({ + kind: 'dir', + entries: [...objects.values()], + label: GIT_DIR, + }) + const tipRoot = plan.nodes[tipRootId] + if (tipRoot.kind !== 'dir') throw new Error('push: tip root is not a directory') + const rootId = plan.add({ + kind: 'dir', + entries: [ + ...tipRoot.entries, + { name: GIT_DIR, isDir: true, ref: { kind: 'node', id: gitStoreId } }, + ], + label: '/', + }) + + await dryRun(opts, plan, rootId) + + const packed = await packContent({ + plan, + store: opts.store, + publish: (outputs) => + opts.publisher.publishContent(outputs, [LABEL_PUSH], opts.sha), + pending: await loadPending(opts.sha, opts.home), + onContent: (txs) => savePending(opts.sha, txs, opts.home), + log: opts.log, + }) + const root = packed.outpoints.get(rootId) + if (!root) throw new Error('push: the root was not published') + const rootStr = formatOutpoint(root, '_') + const origin = opts.origin || rootStr + opts.log?.( + `gib: published ${published} tree(s) and ${reachable.length} commit(s) in ${packed.txs.length} transaction(s)\n`, + ) + + // The peer may not have the chain this push continues — a repository + // minted by `gib init`, or a peer added later. Send what it lacks + // first, so the head minted below never arrives over a gap. + if (opts.prev) await syncPeer(opts, opts.branch, opts.prev.outpoint) + + const branchedFrom = fork?.outpoint ?? (await mergedFrom(opts, tip)) + const head = await opts.publisher.publishHead({ + token: { + origin, + branch: opts.branch, + root: rootStr, + identityPubkey: opts.identity, + branchedFrom, + }, + sha: opts.sha, + labels: [LABEL_PUSH], + tags: headTags(origin, opts.branch, opts.sha), + spend: opts.prev?.spend, + }) + await opts.store.put(head.txid, head.bytes) + await clearPending(opts.sha, opts.home) + const outpoint = `${head.txid}_${head.vout}` + await submitHead(opts, head, packed.txs) + return { origin, head: outpoint, sha: opts.sha, branchedFrom } +} + +/** + * Publish the plan into a throwaway store and read it back with the + * reader a clone would use: every tree must materialise to the sha its + * commit names, `.git` stripped. Nothing is spent until this passes. + */ +async function dryRun( + opts: PushState, + plan: Plan, + rootId: number, +): Promise { + const scratchStore = overlayStore(opts.store) + const packed = await packContent({ + plan, + store: scratchStore, + publish: dryPublish, + }) + const root = packed.outpoints.get(rootId) + if (!root) throw new Error('push: the root was not planned') + const gitDir = await mkdtemp(join(tmpdir(), 'gib-validate-')) + try { + const imported = await importRoot(scratchStore, gitDir, root) + if (imported.tip !== opts.sha) { + throw new Error( + `validation: the published root publishes ${imported.tip}, not ${opts.sha}`, + ) + } + } finally { + await rm(gitDir, { recursive: true, force: true }) + } +} + +/** Send the head and this push's content to the peer. */ +async function submitHead( + opts: PushOptions, + head: PublishedTx, + content: PublishedTx[], +): Promise { + if (!opts.peer) return + const beefs: Array = [head.beef] + for (const tx of content) beefs.push(tx.beef.length ? tx.beef : rawBeef(tx.bytes)) + await opts.peer.submit(atomicWithExtras(beefs, head.txid)) +} + +/** + * The head a new branch forks from: the newest head this client knows + * whose commit is an ancestor of what is being pushed. Its published root + * is what the new branch's first push cites, so forking copies nothing. + */ +async function pickFork( + opts: PushState, +): Promise<{ outpoint: string; root: Outpoint } | undefined> { + const candidates: KnownHead[] = [] + for (const h of opts.knownHeads ?? []) { + if (!h.sha || !h.outpoint) continue + if (h.sha === opts.sha || (await isAncestorQuiet(opts.gitDir, h.sha, opts.sha))) { + candidates.push(h) + } + } + let best: KnownHead | undefined + for (const c of candidates) { + if (!best) { + best = c + continue + } + // Keep whichever is further along the history. + if (await isAncestorQuiet(opts.gitDir, best.sha, c.sha)) best = c + } + if (!best) return undefined + const head = await opts.store.get(parseOutpoint(best.outpoint).txid) + if (!head) return undefined + const tx = Transaction.fromBinary(Array.from(head)) + const out = tx.outputs[parseOutpoint(best.outpoint).vout] + if (!out) return undefined + const token = decodeCommitToken(out.lockingScript) + opts.log?.(`gib: branching from ${best.outpoint}\n`) + return { outpoint: best.outpoint, root: parseOutpoint(token.root) } +} + +/** + * For a merge, the head publishing the parent the spend does not cover. + * The spend is the first parent's lineage; this is the other one, so the + * head's parents mirror the commit's. + */ +async function mergedFrom(opts: PushState, tip: Uint8Array): Promise { + const parents = commitParents(tip) + if (parents.length < 2) return '' + const heads = opts.knownHeads ?? [] + for (const p of parents.slice(1)) { + const exact = heads.find((h) => h.sha === p) + if (exact) return exact.outpoint + } + for (const p of parents.slice(1)) { + for (const h of heads) { + if (!h.sha) continue + if (await isAncestorQuiet(opts.gitDir, h.sha, p)) { + if (!(await isAncestorQuiet(opts.gitDir, h.sha, parents[0]))) { + return h.outpoint + } + } + } + } + // TODO: only one extra parent fits in the token. An octopus merge of + // three or more parents publishes the first two lineages and leaves the + // rest for whichever head publishes them. + return '' +} + +async function isAncestorQuiet( + gitDir: string, + anc: string, + desc: string, +): Promise { + try { + return await isAncestor(gitDir, anc, desc) + } catch { + return false + } +} + +/** + * Bring a peer up to date with heads it does not have, without minting. + * The peer's own copy of the branch says where to start. + */ +async function syncPeer( + opts: PushOptions, + branch: string, + tip: string, +): Promise { + if (!opts.peer) return + const seen = new Set() + let since = '' + for (let page = 0; page < MAX_PAGES; page++) { + const answer = await opts.peer.headsSince({ + origin: opts.origin, + branch, + identity: opts.identity, + since, + }) + for (const h of answer.heads) seen.add(h.outpoint) + if (!answer.more || answer.heads.length === 0) break + const next = answer.heads[answer.heads.length - 1].outpoint + if (next === since) break + since = next + } + const missing: string[] = [] + let cursor: string | undefined = tip + while (cursor && !seen.has(cursor)) { + missing.push(cursor) + cursor = await previousHead(opts.store, cursor).catch(() => undefined) + } + for (const outpoint of missing.reverse()) { + const op = parseOutpoint(outpoint) + const bytes = await opts.store.get(op.txid) + if (!bytes) throw new Error(`sync: ${op.txid} is not in the local store`) + const tx = Transaction.fromBinary(Array.from(bytes)) + const out = tx.outputs[op.vout] + if (!out) throw new Error(`sync: ${outpoint} is not an output`) + const token = decodeCommitToken(out.lockingScript) + const beefs: Array = [rawBeef(bytes)] + // A peer that has never seen this repository needs the content the + // head's tree cites, not just the transaction the root is in: the + // tree reaches back through the whole history. + const cited = await collectTxids(opts.store, parseOutpoint(token.root)) + if (!cited.complete) { + opts.log?.( + `gib: ${outpoint} cites more content than one submission carries; the remote may need a later sync\n`, + ) + } + for (const txid of cited.txids) { + if (txid === op.txid) continue + const content = await opts.store.get(txid) + if (content) beefs.push(rawBeef(content)) + } + await opts.peer.submit(atomicWithExtras(beefs, op.txid)) + } +} + +/** A single raw transaction as a one-transaction BEEF. */ +function rawBeef(bytes: Uint8Array): number[] { + const beef = new Beef() + beef.mergeTransaction(Transaction.fromBinary(Array.from(bytes))) + return beef.toBinary() +} + +type HeadState = { + outpoint: string + root: string + sha: string + spend: SpendHead +} + +/** + * The head to spend for (repository origin, branch) under this wallet's + * identity. The wallet's own unspent basket output is what decides + * spendability; the commit it publishes is read from its tree. + */ +async function currentHead( + opts: PushOptions, + branch: string, +): Promise { + const listed = await opts.wallet.listOutputs({ + basket: GIB_BASKET, + tags: [originTag(opts.origin), branchTag(branch)], + tagQueryMode: 'all', + include: 'entire transactions', + includeTags: true, + includeCustomInstructions: true, + limit: 1, + }) + const o = listed.outputs?.[0] + if (!o?.lockingScript) return undefined + const token = decodeCommitToken(LockingScript.fromHex(o.lockingScript)) + if (token.identityPubkey !== opts.identity) { + throw new Error( + `head ${o.outpoint} belongs to identity ${token.identityPubkey}`, + ) + } + if (!o.customInstructions) { + throw new Error('commit head missing customInstructions') + } + const ci = JSON.parse(o.customInstructions) as { keyID?: string } + if (!ci.keyID) throw new Error('customInstructions missing keyID') + if (!listed.BEEF?.length) { + throw new Error(`wallet returned no BEEF for ${o.outpoint}`) + } + const outpoint = o.outpoint.replace('.', '_') + const known = opts.knownHeads?.find((h) => h.outpoint === outpoint) + // The sha is in the tree, not on the head, so prefer what is already + // known and only read the root when it is not. + const sha = known?.sha || (await tipSha(opts.store, parseOutpoint(token.root))) + return { + outpoint, + root: token.root, + sha, + spend: { + outpoint: o.outpoint, + beef: Array.from(listed.BEEF), + keyID: ci.keyID, + }, + } +} + +async function burnRef( + opts: PushOptions, + dst: string, + branch: string, +): Promise { + const prev = await currentHead(opts, branch) + if (!prev) return { ok: false, dst, error: 'no such ref' } + const burn = await opts.publisher.burnHead({ + ...prev.spend, + labels: [LABEL_DELETE], + }) + await opts.store.put(burn.txid, burn.bytes) + if (opts.peer) { + await opts.peer.submit(atomicWithExtras([burn.beef], burn.txid)) + } + return { + ok: true, + dst, + branch, + sha: NULL_SHA, + head: '', + minted: true, + branchedFrom: '', + } +} + +/** Abort unsigned wallet actions an interrupted push of this sha left. */ +async function abortStaleActions( + wallet: WalletInterface, + sha: string, + log?: (s: string) => void, +): Promise { + let plans: Awaited> + try { + plans = await recoverPush(wallet, sha) + } catch { + return // best effort: wallets without listActions still push + } + for (const p of plans) { + if (p.kind !== 'abort') continue + log?.('gib: aborting a stale wallet action from an interrupted push\n') + await wallet.abortAction({ reference: p.reference }).catch(() => {}) + } +} + +function message(e: unknown): string { + const text = e instanceof Error ? e.message : String(e) + return text.split('\n').map((l) => l.trim()).filter(Boolean).join(' ') +} diff --git a/src/recovery.ts b/src/recovery.ts new file mode 100644 index 0000000..59d163c --- /dev/null +++ b/src/recovery.ts @@ -0,0 +1,43 @@ +import type { WalletInterface } from '@bsv/sdk' +import { LABEL_PUSH } from './token.ts' + +export type PushAction = { + txid?: string + status?: string + reference?: string + description?: string +} + +export type RecoveryPlan = + | { kind: 'published'; txid: string } + | { kind: 'abort'; reference: string } + | { kind: 'unknown' } + +export async function recoverPush( + wallet: WalletInterface, + sha: string, +): Promise { + if (typeof wallet.listActions !== 'function') return [] + // One fixed label for every push; the sha is in the action description. + const listed = await wallet.listActions({ + labels: [LABEL_PUSH], + labelQueryMode: 'any', + limit: 1000, + }) + const actions = ((listed.actions ?? []) as PushAction[]).filter((a) => + (a.description ?? '').includes(sha), + ) + const plans: RecoveryPlan[] = [] + for (const a of actions) { + if (a.txid) { + plans.push({ kind: 'published', 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/refs.ts b/src/refs.ts new file mode 100644 index 0000000..28b7e89 --- /dev/null +++ b/src/refs.ts @@ -0,0 +1,120 @@ +/** + * What the client knows about a repository, on disk. + * + * There is no overlay here: no engine, no database, no chain tracker. The + * client keeps the transactions it has been given in the store and, beside + * them, this: the newest head it has seen for each (identity, branch) on a + * repository origin, and where each branch's last refresh stopped. That is + * enough to advertise refs, to resume a sync, and to know which head to + * walk back from for a fetch. + */ + +import { mkdir, readFile, rename, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { defaultGibHome } from './txstore.ts' + +/** The newest head seen for one publisher's branch. */ +export type RefRecord = { + identity: string + branch: string + /** Head outpoint, `txid_vout`. */ + head: string + /** + * Commit sha the head publishes, once it has been read out of the + * head's tree. Empty until then — a head names no commit. + */ + sha: string + /** Root outpoint of the tree the head publishes. */ + root: string +} + +export type RepoState = { + origin: string + /** The repository's first head: the one whose root is the origin. */ + genesis?: { identity: string; branch: string; head: string } + /** Keyed `/`. */ + refs: Record + /** Where each branch's last refresh from the peer stopped. */ + cursors: Record + /** Branch names seen on this repository, including emptied ones. */ + branches: string[] + /** Things a refresh could not do, for the caller to report. Not saved. */ + warnings: string[] +} + +export function emptyRepoState(origin: string): RepoState { + return { origin, refs: {}, cursors: {}, branches: [], warnings: [] } +} + +export function refKey(identity: string, branch: string): string { + return `${identity}/${branch}` +} + +function statePath(origin: string, home?: string): string { + return join(home ?? defaultGibHome(), 'repos', `${origin}.json`) +} + +export async function loadRepoState( + origin: string, + home?: string, +): Promise { + try { + const raw = await readFile(statePath(origin, home), 'utf8') + const parsed = JSON.parse(raw) as Partial + return { + origin, + genesis: parsed.genesis, + refs: parsed.refs ?? {}, + cursors: parsed.cursors ?? {}, + branches: parsed.branches ?? [], + warnings: [], + } + } catch (e) { + if ((e as NodeJS.ErrnoException).code === 'ENOENT') { + return emptyRepoState(origin) + } + throw e + } +} + +export async function saveRepoState( + state: RepoState, + home?: string, +): Promise { + const path = statePath(state.origin, home) + await mkdir(join(path, '..'), { recursive: true }) + const tmp = `${path}.${process.pid}.tmp` + const { warnings: _, ...saved } = state + await writeFile(tmp, `${JSON.stringify(saved, null, '\t')}\n`) + await rename(tmp, path) +} + +/** Record a head, keeping the newest one per publisher and branch. */ +export function recordHead( + state: RepoState, + head: { identity: string; branch: string; head: string; sha: string; root: string }, +): void { + const key = refKey(head.identity, head.branch) + const before = state.refs[key] + // A head read from a peer arrives without its sha; keep one already + // known for the same head rather than dropping it. + const sha = head.sha || (before?.head === head.head ? before.sha : '') + state.refs[key] = { ...head, sha } + if (!state.branches.includes(head.branch)) state.branches.push(head.branch) + if (!state.genesis && head.root === state.origin) { + state.genesis = { + identity: head.identity, + branch: head.branch, + head: head.head, + } + } +} + +/** Forget a publisher's branch: its head was burned. */ +export function forgetHead( + state: RepoState, + identity: string, + branch: string, +): void { + delete state.refs[refKey(identity, branch)] +} diff --git a/src/remote/advertise.ts b/src/remote/advertise.ts new file mode 100644 index 0000000..3a96627 --- /dev/null +++ b/src/remote/advertise.ts @@ -0,0 +1,105 @@ +/** + * Naming refs for git. + * + * Heads signed by this wallet's identity advertise as plain + * `refs/heads/`; every other publisher's as + * `refs/heads/@/`. With no wallet and no cached + * identity, nothing is bare — a reader sees every branch attributed. + * + * The refs come from what the store holds, not from the wallet's basket: a + * repository is not "the coins I own", it is the heads on a repository + * origin, whoever published them. + */ + +import { parseIdentity } from './url.ts' +import type { RepoState } from '../refs.ts' + +export type Ref = { + sha: string + name: string + /** Who published the head this ref names. */ + identity: string + branch: string + head: string +} + +export function refName(publisher: string, branch: string, me: string): string { + if (me && publisher === me) return `refs/heads/${branch}` + return `refs/heads/@${publisher}/${branch}` +} + +/** + * The (publisher, branch) an advertised ref names. A bare + * `refs/heads/` is the user's own. + */ +export function splitRef( + ref: string, + me: string, +): { publisher: string; branch: string } { + const name = ref.replace(/^refs\/heads\//, '') + if (!name || name === ref) { + throw new Error( + `bad ref ${ref}: gib serves refs/heads/ and refs/heads/@/`, + ) + } + if (!name.startsWith('@')) return { publisher: me, branch: name } + const slash = name.indexOf('/') + if (slash < 0 || slash === name.length - 1) { + throw new Error(`bad ref ${ref}: want refs/heads/@/`) + } + const id = parseIdentity(name.slice(1, slash)) + if (!id) throw new Error(`bad ref ${ref}: not an identity key`) + return { publisher: id, branch: name.slice(slash + 1) } +} + +/** + * Every current head on the repository, named relative to `me`. + * + * A head whose commit could not be read — its tree is not reachable from + * any peer this client can talk to — is not advertised: git is told about + * a ref only when the sha behind it can be produced. + */ +export function advertise(state: RepoState, me: string): Ref[] { + return Object.values(state.refs) + .filter((r) => r.sha) + .map((r) => ({ + sha: r.sha, + name: refName(r.identity, r.branch, me), + identity: r.identity, + branch: r.branch, + head: r.head, + })) + .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) +} + +/** + * The HEAD symref: the branch the repository was created on — the genesis + * head's, the earliest head on the repository origin — then main, master, + * and finally the first ref. For that branch the user's own ref wins over + * a publisher-prefixed one, and the repository owner's over a stranger's. + */ +export function chooseHead( + refs: Ref[], + state: RepoState, + me: string, +): string | undefined { + if (refs.length === 0) return undefined + const owner = state.genesis?.identity ?? '' + const pick = (branch: string): string | undefined => { + if (!branch) return undefined + let ownerRef: string | undefined + let anyRef: string | undefined + for (const r of refs) { + if (r.branch !== branch) continue + if (me && r.identity === me) return r.name + if (owner && r.identity === owner && !ownerRef) ownerRef = r.name + if (!anyRef) anyRef = r.name + } + return ownerRef ?? anyRef + } + for (const branch of [state.genesis?.branch ?? '', 'main', 'master']) { + const name = pick(branch) + if (name) return name + } + return refs[0].name +} diff --git a/src/remote/beef.ts b/src/remote/beef.ts new file mode 100644 index 0000000..1318a62 --- /dev/null +++ b/src/remote/beef.ts @@ -0,0 +1,30 @@ +import { Beef, Utils } from '@bsv/sdk' + +const ATOMIC_BEEF = 0x01010101 + +/** + * Atomic BEEF naming `txid`, carrying everything in `beefs` — including + * transactions that are not the subject's ancestors. + * + * `Beef.toBinaryAtomic` prunes to the subject's dependency closure, which + * would drop the content transaction a head's root lives in: the head + * spends the previous head, not the content. The overlay needs both in one + * submission, so the atomic header is written by hand over the merged BEEF. + */ +export function atomicWithExtras( + beefs: Array, + txid: string, +): Uint8Array { + const merged = new Beef() + for (const b of beefs) merged.mergeBeef(Array.from(b)) + if (!merged.findTxid(txid)) { + throw new Error(`bundle: ${txid} is not in the merged BEEF`) + } + const body = merged.toBinary() + const out = new Uint8Array(4 + 32 + body.length) + new DataView(out.buffer).setUint32(0, ATOMIC_BEEF, true) + const idBytes = Utils.toArray(txid, 'hex') + for (let i = 0; i < 32; i++) out[4 + i] = idBytes[31 - i] + out.set(body, 36) + return out +} diff --git a/src/remote/discover.ts b/src/remote/discover.ts new file mode 100644 index 0000000..9275b74 --- /dev/null +++ b/src/remote/discover.ts @@ -0,0 +1,124 @@ +/** + * BRC-180 overlay discovery. + * + * A domain declares the overlay services it hosts in a `metanet.overlays` + * object in its `/manifest.json`, mapping each BRC-22 topic manager or + * BRC-24 lookup service name to the base URL that serves it. gib's are + * `tm_gib` (submit) and `ls_gib` (lookup): + * + * {"metanet":{"overlays":{ + * "tm_gib":"https://api.1sat.app/1sat/gib/overlay", + * "ls_gib":"https://api.1sat.app/1sat/gib/overlay"}}} + * + * That is what makes gib://gibhub.net/ work: gibhub.net + * is a website serving no overlay, and its manifest names the host that + * does. A declared value is used verbatim — submission posts to it + + * "/submit", lookup to it + "/lookup", and nothing else is appended. + * + * When the manifest is missing, unreadable, has no `metanet.overlays`, or + * declares neither of gib's services, the host the user named is used as + * the overlay itself, at the path 1sat-stack mounts by default. That is not + * probing — which the spec forbids — it is contacting exactly the host in + * the gib:// URL and nothing else. api.1sat.app serves no manifest and + * works this way. Do not "fix" this into guessing another hostname. + */ + +import { type GibUrl, hostBaseUrl } from './url.ts' + +/** BRC-22 topic manager name for gib commit heads. */ +export const TOPIC_NAME = 'tm_gib' +/** BRC-24 lookup service name for gib commit heads. */ +export const LOOKUP_NAME = 'ls_gib' +/** Where 1sat-stack mounts the gib overlay when a peer declares nothing. */ +export const DEFAULT_OVERLAY_PATH = '/1sat/gib/overlay' + +const MANIFEST_PATH = '/manifest.json' +/** The manifest fetch runs before the first peer request; fail fast. */ +const DISCOVER_TIMEOUT_MS = 5_000 +const MAX_MANIFEST = 1 << 20 + +export type Endpoints = { + /** BRC-22 endpoint; submission posts to `${submit}/submit`. */ + submit: string + /** BRC-24 endpoint; a lookup posts to `${lookup}/lookup`. */ + lookup: string +} + +export type DiscoverOptions = { + fetchImpl?: typeof fetch + /** Skip the process-wide cache (tests). */ + noCache?: boolean +} + +const cache = new Map() + +/** Forget every cached manifest answer (tests). */ +export function clearDiscoveryCache(): void { + cache.clear() +} + +export async function resolveEndpoints( + url: GibUrl, + opts: DiscoverOptions = {}, +): Promise { + // A local-only URL names no peer, so there is nothing to resolve. + if (!url.host) return { submit: '', lookup: '' } + const base = hostBaseUrl(url.host) + const fallback: Endpoints = { + submit: base + DEFAULT_OVERLAY_PATH, + lookup: base + DEFAULT_OVERLAY_PATH, + } + if (!opts.noCache) { + const hit = cache.get(url.host) + if (hit) return hit + } + const declared = await declaredOverlays(base, opts.fetchImpl ?? fetch) + const resolved: Endpoints = { + submit: declared.submit || fallback.submit, + lookup: declared.lookup || fallback.lookup, + } + if (!opts.noCache) cache.set(url.host, resolved) + return resolved +} + +async function declaredOverlays( + hostBase: string, + fetchImpl: typeof fetch, +): Promise<{ submit: string; lookup: string }> { + const none = { submit: '', lookup: '' } + try { + const res = await fetchImpl(hostBase + MANIFEST_PATH, { + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(DISCOVER_TIMEOUT_MS), + }) + if (!res.ok) return none + const text = (await res.text()).slice(0, MAX_MANIFEST) + const doc = JSON.parse(text) as { + metanet?: { overlays?: Record } + } + // Only gib's own keys are read; every other key is ignored, as the + // spec requires. + const overlays = doc?.metanet?.overlays + if (!overlays || typeof overlays !== 'object') return none + return { + submit: endpointUrl(overlays[TOPIC_NAME]), + lookup: endpointUrl(overlays[LOOKUP_NAME]), + } + } catch { + return none + } +} + +/** A declared endpoint, or "" when it is not a usable http(s) URL. */ +function endpointUrl(declared: unknown): string { + if (typeof declared !== 'string') return '' + const s = declared.trim().replace(/\/+$/, '') + try { + const u = new URL(s) + if (u.protocol !== 'http:' && u.protocol !== 'https:') return '' + if (!u.host) return '' + return s + } catch { + return '' + } +} diff --git a/src/remote/helper.ts b/src/remote/helper.ts new file mode 100644 index 0000000..18a9b59 --- /dev/null +++ b/src/remote/helper.ts @@ -0,0 +1,269 @@ +/** + * The git remote-helper protocol for gib: capabilities, list, fetch, push. + * + * A git remote is one peer on one repository origin + * (`gib:///`). `list` refreshes every publisher's + * heads from the peer and advertises the user's own as + * `refs/heads/` and every other publisher's as + * `refs/heads/@/`. `fetch` walks a branch's spend chain + * back into git. `push` mints through the wallet and submits to the peer. + * + * Nothing here validates a chain: the peer's overlay does that, and this + * client asked it for what it got. + */ + +import type { WalletInterface } from '@bsv/sdk' +import { importHead } from '../fetch.ts' +import { loadIdentity, saveIdentity } from '../identity.ts' +import { pushLine } from '../push.ts' +import { type Publisher, walletPublisher } from '../publish.ts' +import { + emptyRepoState, + forgetHead, + loadRepoState, + recordHead, + type RepoState, + saveRepoState, +} from '../refs.ts' +import { readHead } from '../head.ts' +import { NULL_SHA } from '../token.ts' +import type { TxStore } from '../txstore.ts' +import { advertise, chooseHead, type Ref, splitRef } from './advertise.ts' +import type { Peer } from './peer.ts' +import { pullRepo } from './sync.ts' +import { parseGibUrl } from './url.ts' + +export type HelperIo = { + read: () => Promise + write: (s: string) => void +} + +export type HelperOptions = { + url: string + store: TxStore + /** The peer the URL names, when it names one. */ + peer?: Peer + /** Connects the wallet; only a push needs one. */ + wallet?: () => Promise + /** Overrides the wallet-backed publisher (tests). */ + publisher?: Publisher + gitDir: string + io: HelperIo + home?: string + /** Where progress lines go; git relays stderr to the user. */ + log?: (s: string) => void + /** Branch names the local repository knows, to help a first refresh. */ + localBranches?: string[] +} + +export async function runHelper(opts: HelperOptions): Promise { + const { origin } = parseGibUrl(opts.url) + const log = opts.log ?? ((s: string) => process.stderr.write(s)) + const state = await loadRepoState(origin, opts.home) + let identity = await loadIdentity(opts.home) + + /** + * Refresh from the peer and advertise. + * + * For a push it is the *peer's* own view that git must compare against, + * not everything this client knows: a head minted here and never sent + * (a repository straight out of `gib init`, or a second remote added + * later) would otherwise look to git like something the remote already + * has, and git would send nothing. So the peer's answer is collected + * into a state of its own, and merged into ours afterwards. + */ + const refresh = async (forPush = false): Promise => { + if (!opts.peer) return advertise(state, identity) + const view = forPush ? emptyRepoState(origin) : state + if (forPush) view.branches = [...state.branches] + try { + const added = await pullRepo( + opts.peer, + opts.store, + view, + opts.localBranches ?? [], + ) + if (added > 0 && !forPush) { + log(`gib: fetched ${added} head(s) from the remote\n`) + } + for (const w of view.warnings.splice(0)) log(`gib: ${w}\n`) + } catch (e) { + log(`gib: ${e instanceof Error ? e.message : e}\n`) + } + if (forPush) mergeState(state, view) + await saveRepoState(state, opts.home) + return advertise(view, identity) + } + + for (;;) { + const line = await opts.io.read() + if (line === null) return + const cmd = line.trim() + if (cmd === '') continue + if (cmd === 'capabilities') { + opts.io.write('fetch\npush\n\n') + continue + } + if (cmd === 'list' || cmd === 'list for-push') { + const refs = await refresh(cmd === 'list for-push') + for (const r of refs) opts.io.write(`${r.sha} ${r.name}\n`) + const head = chooseHead(refs, state, identity) + if (head) opts.io.write(`@${head} HEAD\n`) + opts.io.write('\n') + continue + } + if (cmd.startsWith('fetch ')) { + const lines = [cmd, ...(await readUntilBlank(opts.io))] + for (const l of lines) { + const [, sha, ref] = l.split(' ') + try { + await fetchRef(opts, state, identity, sha, ref ?? '', log) + } catch (e) { + // Leaving the conversation mid-protocol makes git report + // a helper that died. Say what went wrong and finish the + // batch; git will notice the objects it wanted are + // missing and say so in its own words. + log(`gib: fetch ${sha.slice(0, 12)}: ${oneLine(e)}\n`) + } + } + await saveRepoState(state, opts.home) + opts.io.write('\n') + continue + } + if (cmd.startsWith('push ')) { + const lines = [cmd, ...(await readUntilBlank(opts.io))] + let wallet: WalletInterface | undefined + try { + if (!opts.wallet) throw new Error('no wallet configured') + wallet = await opts.wallet() + const got = await wallet.getPublicKey({ identityKey: true }) + identity = got.publicKey + await saveIdentity(identity, opts.home).catch(() => {}) + } catch (e) { + for (const l of lines) { + opts.io.write(`error ${dstOf(l)} ${oneLine(e)}\n`) + } + opts.io.write('\n') + continue + } + await refresh(true) + const publisher = opts.publisher ?? walletPublisher(wallet) + for (const l of lines) { + const r = await pushLine(l, { + gitDir: opts.gitDir, + store: opts.store, + wallet, + publisher, + origin, + identity, + peer: opts.peer, + knownHeads: Object.values(state.refs).map((r) => ({ + outpoint: r.head, + sha: r.sha, + identity: r.identity, + branch: r.branch, + root: r.root, + })), + home: opts.home, + log, + }) + if (!r.ok) { + opts.io.write(`error ${r.dst} ${r.error}\n`) + continue + } + if (r.sha === NULL_SHA) forgetHead(state, identity, r.branch) + else if (r.head) { + const head = await readHead(opts.store, r.head) + recordHead(state, { + identity, + branch: r.branch, + head: r.head, + sha: r.sha, + root: head.token.root, + }) + } + if (r.branchedFrom) { + log(`gib: ${r.branch} branches from ${r.branchedFrom}\n`) + } + opts.io.write(`ok ${r.dst}\n`) + } + await saveRepoState(state, opts.home) + opts.io.write('\n') + continue + } + // Only fetch and push are advertised, so git should never send + // anything else; if it does, ignoring the line beats dying. + log(`gib: ignoring unsupported command ${cmd}\n`) + } +} + +/** Fold a peer's view of a repository into what this client keeps. */ +function mergeState(state: RepoState, view: RepoState): void { + for (const r of Object.values(view.refs)) recordHead(state, r) + for (const [branch, cursor] of Object.entries(view.cursors)) { + state.cursors[branch] = cursor + } +} + +/** Import the history behind one advertised ref. */ +async function fetchRef( + opts: HelperOptions, + state: RepoState, + identity: string, + sha: string, + ref: string, + log: (s: string) => void, +): Promise { + let head = headFor(state, identity, sha, ref) + if (!head && opts.peer) { + await pullRepo(opts.peer, opts.store, state, opts.localBranches ?? []) + head = headFor(state, identity, sha, ref) + } + if (!head) { + throw new Error(`no head on ${state.origin} publishes commit ${sha}`) + } + const imported = await importHead(opts.store, opts.gitDir, head, opts.peer) + log( + `gib: imported ${imported.commits} commit(s) and ${imported.trees} tree(s) for ${sha.slice(0, 12)}\n`, + ) +} + +function headFor( + state: RepoState, + identity: string, + sha: string, + ref: string, +): string | undefined { + if (ref) { + try { + const { publisher, branch } = splitRef(ref, identity) + for (const r of Object.values(state.refs)) { + if (r.branch === branch && r.identity === publisher && r.sha === sha) { + return r.head + } + } + } catch { + // fall through to the sha search + } + } + return Object.values(state.refs).find((r) => r.sha === sha)?.head +} + +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 +} + +function dstOf(line: string): string { + return line.slice(line.lastIndexOf(':') + 1) +} + +function oneLine(e: unknown): string { + const text = e instanceof Error ? e.message : String(e) + return text.split('\n').map((l) => l.trim()).filter(Boolean).join(' ') +} diff --git a/src/remote/peer.ts b/src/remote/peer.ts new file mode 100644 index 0000000..7af5c6c --- /dev/null +++ b/src/remote/peer.ts @@ -0,0 +1,269 @@ +/** + * The peer client: BRC-24 lookups on `ls_gib` and BRC-22 submission to + * `tm_gib`. Syncing is these two lookups, not REST, so a repository can be + * followed on a domain that declares only an overlay endpoint (BRC-180). + * + * headsSince one branch's heads from a point forward, oldest first, + * each carrying its own BEEF + * txs whole transactions by txid, as one merged BEEF + * + * Both post to `${lookup}/lookup` as a BRC-24 question. The answer's + * `result` arrives JSON-encoded *into a string*, which is the BRC-24 + * response shape, so it is parsed twice. + * + * The client validates nothing on chain: no merkle proof is checked here + * and there is no engine behind this. The server validates; the client + * asked for what it got. + */ + +import { Utils } from '@bsv/sdk' +import { + type Endpoints, + LOOKUP_NAME, + TOPIC_NAME, + resolveEndpoints, +} from './discover.ts' +import { type GibUrl, isRemote } from './url.ts' + +/** Most heads one headsSince page can carry (the overlay's MaxLimit). */ +export const MAX_HEADS_SINCE = 100 +/** + * Most pages one branch walk will take. A peer that keeps saying `more` + * without advancing would otherwise spin for ever; at 100 heads a page + * this is a million commits, which is not a branch anyone is pushing. + */ +export const MAX_PAGES = 10_000 +/** Most transactions one txs request may ask for; over it, it is rejected. */ +export const MAX_TXIDS = 50 + +const DEFAULT_TIMEOUT_MS = 120_000 +const MAX_ANSWER = 64 << 20 + +/** One head from a headsSince page. */ +export type SyncHead = { + /** `txid_vout` of the head output. */ + outpoint: string + vout: number + /** BEEF of the transaction that created the head. */ + beef: Uint8Array +} + +/** + * The peer cannot answer from where local state left off. Nothing can be + * synced until the local copy is repaired. + */ +export class SyncBrokenError extends Error { + readonly code: string + constructor(message: string, code: string) { + super(message) + this.name = 'SyncBrokenError' + this.code = code + } +} + +type RawAnswer = { + type?: string + outputs?: Array<{ beef?: unknown; outputIndex?: number }> + result?: unknown +} + +type HeadsSinceResult = { + outpoints?: string[] + more?: boolean + code?: string +} + +export type PeerOptions = { + fetchImpl?: typeof fetch + timeoutMs?: number +} + +export class Peer { + readonly endpoints: Endpoints + private readonly fetchImpl: typeof fetch + private readonly timeoutMs: number + + constructor(endpoints: Endpoints, opts: PeerOptions = {}) { + this.endpoints = { + submit: endpoints.submit.replace(/\/+$/, ''), + lookup: endpoints.lookup.replace(/\/+$/, ''), + } + this.fetchImpl = opts.fetchImpl ?? fetch + this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS + } + + /** One BRC-24 question; returns the answer with `result` already parsed. */ + private async lookup(query: unknown): Promise { + const url = `${this.endpoints.lookup}/lookup` + let res: Response + try { + res = await this.fetchImpl(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ service: LOOKUP_NAME, query }), + signal: AbortSignal.timeout(this.timeoutMs), + }) + } catch (e) { + throw new Error( + `remote lookup ${url}: ${e instanceof Error ? e.message : e}`, + ) + } + const text = await res.text() + if (!res.ok) { + throw new Error( + `remote lookup: HTTP ${res.status}: ${text.slice(0, 4096).trim()}`, + ) + } + if (text.length > MAX_ANSWER) { + throw new Error( + `remote lookup: answer is ${text.length} bytes, over the ${MAX_ANSWER} limit`, + ) + } + let answer: RawAnswer + try { + answer = JSON.parse(text) as RawAnswer + } catch (e) { + throw new Error( + `remote lookup: ${e instanceof Error ? e.message : 'bad JSON'}`, + ) + } + // BRC-24 answers carry `result` as a JSON document encoded into a + // string; an overlay that hands back an object is accepted too. + if (typeof answer.result === 'string' && answer.result !== '') { + try { + answer.result = JSON.parse(answer.result) as unknown + } catch (e) { + throw new Error( + `remote lookup result: ${e instanceof Error ? e.message : 'bad JSON'}`, + ) + } + } + return answer + } + + /** + * One branch's heads after `since` (exclusive), oldest first. An empty + * `since` starts at the branch's first head; an empty identity takes + * every publisher's. + * + * A peer that cannot answer from `since` says so in the result rather + * than as an HTTP error, because the overlay collapses lookup errors to + * an opaque 500. Those come back as SyncBrokenError. + */ + async headsSince(q: { + origin: string + branch: string + identity?: string + since?: string + limit?: number + }): Promise<{ heads: SyncHead[]; more: boolean }> { + const answer = await this.lookup({ + type: 'headsSince', + origin: q.origin, + branch: q.branch, + ...(q.identity ? { identity: q.identity } : {}), + ...(q.since ? { since: q.since } : {}), + limit: Math.min(q.limit ?? MAX_HEADS_SINCE, MAX_HEADS_SINCE), + }) + const result = (answer.result ?? {}) as HeadsSinceResult + switch (result.code ?? '') { + case '': + break + case 'unknown-since': + throw new SyncBrokenError( + `the peer does not know head ${q.since} on branch ${q.branch}`, + 'unknown-since', + ) + case 'missing-beef': + throw new SyncBrokenError( + `the peer's copy of branch ${q.branch} stops at a head whose transaction it no longer has`, + 'missing-beef', + ) + default: + throw new SyncBrokenError( + `branch ${q.branch}: ${result.code}`, + String(result.code), + ) + } + const outputs = answer.outputs ?? [] + const outpoints = result.outpoints ?? [] + if (outputs.length !== outpoints.length) { + throw new Error( + `remote lookup: ${outputs.length} outputs for ${outpoints.length} outpoints`, + ) + } + const heads = outputs.map((o, i) => ({ + outpoint: outpoints[i], + vout: o.outputIndex ?? 0, + beef: toBytes(o.beef, 'head beef'), + })) + return { heads, more: result.more === true } + } + + /** + * Whole transactions as one merged BEEF, with whatever proofs the peer + * has. Transactions it does not hold are simply absent; holding none of + * them is a valid empty BEEF. At most MAX_TXIDS per call. + */ + async txs(txids: string[]): Promise { + if (txids.length === 0) return new Uint8Array(0) + if (txids.length > MAX_TXIDS) { + throw new Error( + `remote txs: ${txids.length} txids requested, at most ${MAX_TXIDS} per request`, + ) + } + const answer = await this.lookup({ type: 'txs', txids }) + const result = (answer.result ?? {}) as { beef?: unknown } + if (result.beef === undefined || result.beef === null) { + return new Uint8Array(0) + } + return toBytes(result.beef, 'txs beef') + } + + /** Post an atomic BEEF to the peer's BRC-22 endpoint for tm_gib. */ + async submit(atomicBeef: Uint8Array): Promise { + const url = `${this.endpoints.submit}/submit` + let res: Response + try { + res = await this.fetchImpl(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/octet-stream', + 'x-topics': TOPIC_NAME, + }, + body: atomicBeef as unknown as BodyInit, + signal: AbortSignal.timeout(this.timeoutMs), + }) + } catch (e) { + throw new Error( + `remote submit ${url}: ${e instanceof Error ? e.message : e}`, + ) + } + if (!res.ok) { + const body = (await res.text()).slice(0, 4096) + throw new Error(`remote submit: HTTP ${res.status}: ${body.trim()}`) + } + } +} + +/** The peer a remote URL names, or undefined for a local-only URL. */ +export async function peerFor( + url: GibUrl, + opts: PeerOptions = {}, +): Promise { + if (!isRemote(url)) return undefined + const endpoints = await resolveEndpoints(url, { fetchImpl: opts.fetchImpl }) + return new Peer(endpoints, opts) +} + +/** Byte fields arrive base64 (Go) or as a number array (JS overlays). */ +function toBytes(value: unknown, what: string): Uint8Array { + if (typeof value === 'string') { + return new Uint8Array(Utils.toArray(value, 'base64')) + } + if (Array.isArray(value)) return new Uint8Array(value as number[]) + throw new Error(`remote lookup: ${what} is not bytes`) +} diff --git a/src/remote/store.ts b/src/remote/store.ts new file mode 100644 index 0000000..f1ce252 --- /dev/null +++ b/src/remote/store.ts @@ -0,0 +1,24 @@ +import type { FetchRawTx } from '../txstore.ts' +import { Beef } from '@bsv/sdk' +import type { Peer } from './peer.ts' + +/** + * The store's last resort when something asks for a transaction nobody + * prefetched: one `txs` question for that transaction alone. The batched + * path in sync.ts is what should normally fill the store; this keeps a + * stray read from failing. + */ +export function peerFetchRawTx(peer?: Peer): FetchRawTx | undefined { + if (!peer) return undefined + return async (txid: string) => { + try { + const bytes = await peer.txs([txid]) + if (bytes.length === 0) return undefined + const beef = Beef.fromBinary(Array.from(bytes)) + const tx = beef.findTxid(txid)?.tx + return tx ? new Uint8Array(tx.toBinary()) : undefined + } catch { + return undefined + } + } +} diff --git a/src/remote/sync.ts b/src/remote/sync.ts new file mode 100644 index 0000000..a3f43a7 --- /dev/null +++ b/src/remote/sync.ts @@ -0,0 +1,316 @@ +/** + * Syncing with a peer: the two lookups and nothing else. + * + * `headsSince` walks one branch forward, oldest first, and every head + * arrives with its own BEEF, so a head is always stored before the push + * that spends it. `txs` fetches whole transactions in batches, which is + * how the trees those heads point at are filled in. + * + * Which branches a repository has is not something either lookup answers — + * headsSince takes one branch — so the branch list comes from what this + * client already knows, plus the repository's own `.gib` default branch + * and the conventional names. See the TODO on branchCandidates. + */ + +import { Beef } from '@bsv/sdk' +import { gitHash } from '../git.ts' +import { readHead } from '../head.ts' +import { GIT_DIR, readDir } from '../tree.ts' +import { DIR_CONTENT_TYPE, dirDecode } from '../ordfs/dir.ts' +import { PATCH_CONTENT_TYPE, patchDecode } from '../ordfs/patch.ts' +import { formatOutpoint, type Outpoint, parseOutpoint } from '../outpoint.ts' +import { payloadFromScript } from '../content.ts' +import { GIB_FILE, parseRepoMeta } from '../repo-meta.ts' +import { recordHead, type RepoState } from '../refs.ts' +import { loadTx, resolveOutpoint, resolvePath } from '../resolver.ts' +import type { TxStore } from '../txstore.ts' +import { MAX_PAGES, MAX_TXIDS, type Peer, SyncBrokenError } from './peer.ts' + +/** Branch names tried when nothing better is known about a repository. */ +export const CONVENTIONAL_BRANCHES = ['main', 'master'] + +/** + * Bring the local state up to date with the peer's copy of one branch. + * Returns the heads that were new here. + * + * TODO: a headsSince page is a walk of the branch's chain, and says + * nothing about whether its last head has since been spent. A branch + * deleted by burning its head therefore still advertises here, to anyone + * who learns about it from a peer rather than from their own delete. The + * overlay knows (it tracks the spend); the answer needs to carry it — a + * spent flag on each head, or a tombstone for the branch. + */ +export async function pullBranch( + peer: Peer, + store: TxStore, + state: RepoState, + branch: string, +): Promise { + let since = state.cursors[branch] ?? '' + let added = 0 + for (let page1 = 0; ; page1++) { + if (page1 >= MAX_PAGES) { + throw new Error( + `branch ${branch}: the peer is still offering more heads after ${MAX_PAGES} pages`, + ) + } + let page: Awaited> + try { + page = await peer.headsSince({ origin: state.origin, branch, since }) + } catch (e) { + // A peer that never saw where we stopped cannot resume us: start + // the branch again rather than mistaking its whole history for + // new work or giving up on it. + if (e instanceof SyncBrokenError && e.code === 'unknown-since' && since) { + since = '' + delete state.cursors[branch] + continue + } + throw e + } + for (const h of page.heads) { + const stored = await absorbBeef(store, h.beef) + const txid = parseOutpoint(h.outpoint).txid + if (!stored.includes(txid)) { + // The outpoints are index-aligned with the outputs; a peer + // whose BEEF does not hold the head it is answering about + // has given us something we cannot use. + throw new Error( + `the peer's BEEF for head ${h.outpoint} does not contain ${txid}`, + ) + } + const head = await readHead(store, h.outpoint) + if (head.token.origin !== state.origin || head.token.branch !== branch) { + continue + } + // A head names no commit: the commit it publishes is in its + // tree. Record the head now and read the sha once, for the + // branch's newest head only, when the walk is done. + recordHead(state, { + identity: head.token.identityPubkey, + branch, + head: head.outpoint, + sha: '', + root: head.token.root, + }) + state.cursors[branch] = h.outpoint + added++ + } + if (!page.more || page.heads.length === 0) { + await resolveShas(peer, store, state, branch) + return added + } + const next = page.heads[page.heads.length - 1].outpoint + if (next === since) { + throw new Error(`branch ${branch}: the peer is not advancing past ${since}`) + } + since = next + } +} + +/** + * Fill in the commit sha of each publisher's newest head on a branch. It + * lives in the head's tree, so this is the one place a ref listing has to + * read content — once per branch, not once per head. + */ +async function resolveShas( + peer: Peer, + store: TxStore, + state: RepoState, + branch: string, +): Promise { + for (const ref of Object.values(state.refs)) { + if (ref.branch !== branch || ref.sha) continue + try { + ref.sha = await tipShaFrom(peer, store, parseOutpoint(ref.root)) + } catch (e) { + // Without the sha there is nothing to advertise; the ref stays + // recorded, so a later refresh can try again. + state.warnings.push( + `head ${ref.head}: ${e instanceof Error ? e.message : e}`, + ) + } + } +} + +/** + * The commit a published root publishes, fetching only what it takes to + * read it: the root manifest, the `.git` manifest, and the tip commit. + */ +export async function tipShaFrom( + peer: Peer | undefined, + store: TxStore, + root: Outpoint, +): Promise { + await ensureTxs(store, peer, [root.txid]) + const gitEntry = (await readDir(store, root)).find( + (e) => e.name === GIT_DIR && e.isDir, + ) + if (!gitEntry) throw new Error(`published root has no ${GIT_DIR} store`) + await ensureTxs(store, peer, [gitEntry.outpoint.txid]) + const tip = (await readDir(store, gitEntry.outpoint)).find((e) => e.name === '.') + if (!tip) throw new Error(`${GIT_DIR} names no tip commit`) + await ensureTxs(store, peer, [tip.outpoint.txid]) + const payload = await resolveOutpoint(store, tip.outpoint) + return gitHash('commit', payload.bytes) +} + +/** + * Refresh every branch this client can name. Returns the number of heads + * that were new. + */ +export async function pullRepo( + peer: Peer, + store: TxStore, + state: RepoState, + extraBranches: string[] = [], +): Promise { + let added = 0 + for (const branch of await branchCandidates(peer, store, state, extraBranches)) { + added += await pullBranch(peer, store, state, branch) + } + return added +} + +/** + * The branches to ask a peer about. + * + * TODO: ls_gib has no query that enumerates a repository's branches — + * headsSince takes one branch, and the untyped `heads` query answers with + * formulas the overlay engine cannot hydrate. Until it grows one, a branch + * is found from what this client already knows, the local repository's own + * refs, the genesis tree's `.gib` defaultBranch, and main/master. A + * repository whose only branch is none of those cannot be cloned blind. + */ +export async function branchCandidates( + peer: Peer, + store: TxStore, + state: RepoState, + extra: string[] = [], +): Promise { + const names = new Set(state.branches) + for (const b of extra) if (b) names.add(b) + if (state.genesis) names.add(state.genesis.branch) + const meta = await defaultBranchFromGenesis(peer, store, state.origin) + if (meta) names.add(meta) + for (const b of CONVENTIONAL_BRANCHES) names.add(b) + return [...names] +} + +/** `.gib` defaultBranch from the repository's genesis tree, when it has one. */ +async function defaultBranchFromGenesis( + peer: Peer, + store: TxStore, + origin: string, +): Promise { + try { + const root = parseOutpoint(origin) + await ensureTxs(store, peer, [root.txid]) + await prefetchTree(store, peer, root) + const file = await resolvePath(store, root, GIB_FILE) + return parseRepoMeta(new TextDecoder().decode(file.bytes)).defaultBranch + } catch { + return undefined + } +} + +/** Store every transaction a BEEF carries. */ +export async function absorbBeef( + store: TxStore, + beef: Uint8Array | number[], +): Promise { + const parsed = Beef.fromBinary(Array.from(beef)) + const stored: string[] = [] + for (const btx of parsed.txs) { + if (!btx.tx) continue + const txid = btx.tx.id('hex') + await store.put(txid, new Uint8Array(btx.tx.toBinary())) + stored.push(txid) + } + return stored +} + +/** Fetch the transactions the store does not have, in batches. */ +export async function ensureTxs( + store: TxStore, + peer: Peer | undefined, + txids: Iterable, +): Promise { + const missing: string[] = [] + for (const txid of new Set([...txids].map((t) => t.toLowerCase()))) { + if (!(await store.get(txid))) missing.push(txid) + } + if (missing.length === 0) return + if (!peer) { + throw new Error( + `missing ${missing.length} transaction(s) and no peer to ask: ${missing[0]}`, + ) + } + for (let i = 0; i < missing.length; i += MAX_TXIDS) { + const batch = missing.slice(i, i + MAX_TXIDS) + await absorbBeef(store, await peer.txs(batch)) + } +} + +/** + * Walk a published tree breadth-first, fetching the transactions each + * level cites before reading it. One `txs` request per level, rather than + * one per file, is the whole point of the query. + */ +export async function prefetchTree( + store: TxStore, + peer: Peer | undefined, + root: Outpoint, + maxDepth = 64, +): Promise { + let level: Outpoint[] = [root] + const seen = new Set() + for (let depth = 0; depth < maxDepth && level.length > 0; depth++) { + await ensureTxs(store, peer, level.map((op) => op.txid)) + const next: Outpoint[] = [] + for (const op of level) { + const key = formatOutpoint(op) + if (seen.has(key)) continue + seen.add(key) + const children = await childOutpoints(store, op) + for (const child of children) { + if (!seen.has(formatOutpoint(child))) next.push(child) + } + } + level = next + } +} + +/** The outpoints an output points at: directory entries, or a patch base. */ +async function childOutpoints( + store: TxStore, + op: Outpoint, +): Promise { + let payload: ReturnType + try { + const tx = await loadTx(store, op.txid) + const out = tx.outputs[op.vout] + if (!out) return [] + payload = payloadFromScript(out.lockingScript) + } catch { + return [] + } + if (!payload) return [] + if (payload.contentType === PATCH_CONTENT_TYPE) { + try { + return [patchDecode(payload.bytes).base] + } catch { + return [] + } + } + if (payload.contentType !== DIR_CONTENT_TYPE) return [] + try { + return dirDecode(payload.bytes).entries.map((e) => + e.ref.kind === 'same-tx' + ? { txid: op.txid, vout: e.ref.vout } + : { txid: e.ref.txid.toLowerCase(), vout: e.ref.vout }, + ) + } catch { + return [] + } +} diff --git a/src/remote/url.ts b/src/remote/url.ts new file mode 100644 index 0000000..ce13769 --- /dev/null +++ b/src/remote/url.ts @@ -0,0 +1,87 @@ +/** + * `gib://` remote URLs. A git remote is one peer on one repository: + * + * gib:/// the peer overlay at that host + * gib:// local only, no peer + * + * "repository origin" is always the genesis `ordfs/dir` root outpoint — + * never bare "origin", which ordinals and git both already use for + * something else. No identity appears in the URL: every head names its + * signer, and which heads are the user's own comes from the wallet. + */ + +import { formatOutpoint, parseOutpoint } from '../outpoint.ts' + +export type GibUrl = { + /** Empty for a local-only URL. */ + host: string + /** Repository origin, normalised to `txid_vout`. */ + origin: string +} + +const USAGE = + 'gib URLs are gib:/// or gib://' + +/** A compressed identity key in lowercase hex, or undefined. */ +export function parseIdentity(s: string): string | undefined { + const id = s.trim().toLowerCase() + if (!/^0[23][0-9a-f]{64}$/.test(id)) return undefined + return id +} + +export function parseGibUrl(s: string): GibUrl { + const raw = s.trim() + if (!raw.startsWith('gib://')) { + throw new Error(`not a gib:// url: ${s} (${USAGE})`) + } + const rest = raw.slice('gib://'.length).replace(/^\/+|\/+$/g, '') + const parts = rest.split('/') + let host = '' + let origin: string + if (parts.length === 1) { + origin = parts[0] + } else if (parts.length === 2) { + host = parts[0] + origin = parts[1] + if (!host || /[\s?#@]/.test(host)) { + throw new Error(`bad gib url ${s}: invalid host "${host}"`) + } + if (parseIdentity(host)) { + throw new Error( + `bad gib url ${s}: an identity is not part of the URL; use gib:///${origin} or gib://${origin}`, + ) + } + } else { + throw new Error(`bad gib url ${s}: ${USAGE}`) + } + let op: ReturnType + try { + op = parseOutpoint(origin) + } catch (e) { + throw new Error( + `bad gib url ${s}: repository origin: ${e instanceof Error ? e.message : e} (${USAGE})`, + ) + } + return { host, origin: formatOutpoint(op, '_') } +} + +export function formatGibUrl(u: GibUrl): string { + return u.host ? `gib://${u.host}/${u.origin}` : `gib://${u.origin}` +} + +export function isRemote(u: GibUrl): boolean { + return u.host !== '' +} + +/** Loopback hosts are reached over plain HTTP; everything else over HTTPS. */ +export function hostBaseUrl(host: string): string { + const h = host.toLowerCase() + const local = + h === 'localhost' || + h.startsWith('localhost:') || + h === '127.0.0.1' || + h.startsWith('127.0.0.1:') || + h === '[::1]' || + h.startsWith('[::1]:') + return `${local ? 'http' : 'https'}://${host}` +} diff --git a/src/repo-meta.ts b/src/repo-meta.ts new file mode 100644 index 0000000..8bc401b --- /dev/null +++ b/src/repo-meta.ts @@ -0,0 +1,65 @@ +/** + * `.gib` — repository metadata, a committed dotfile at the tree root. + * + * { "name": "my-repo", "description": "…", "defaultBranch": "main" } + * + * gib itself never needs it: the repository origin is the repository's + * identity, and the branch a repository was created on is the genesis + * head's. The file gives humans and indexers (gibhub, the overlay) a + * display name. + * + * `defaultBranch` is still written for one reason: no lookup enumerates a + * repository's branches, so a clone that has never heard of this + * repository has nothing else to ask a peer for. It goes when that query + * exists. + * + * The file may later carry publishing hints — how deep a patch chain to + * allow before writing a file whole, how many outputs to put in one + * transaction, how large a stream to publish at once. They would be + * hints a client MAY honour and nothing more: a reader cannot check them, + * a publisher cannot be made to follow them, and every one of them is a + * preference about cost, not a rule about format. None is implemented; + * the client uses its own defaults and would go on doing so. + */ + +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/src/resolver.ts b/src/resolver.ts new file mode 100644 index 0000000..105b147 --- /dev/null +++ b/src/resolver.ts @@ -0,0 +1,130 @@ +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 } 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) +} diff --git a/src/script.ts b/src/script.ts new file mode 100644 index 0000000..204b7d7 --- /dev/null +++ b/src/script.ts @@ -0,0 +1,16 @@ +import { buildDataScript } from '@1sat/actions' +import { OP, type Script } from '@bsv/sdk' + +/** Content type of a git commit object published in a tree's `.git`. */ +export const GIT_COMMIT_TYPE = 'application/x-git-commit' + +/** Standalone zero-sat data output (OP_FALSE OP_RETURN | B), built by the SDK. */ +export function bLockingScript(contentType: string, body: Uint8Array) { + return buildDataScript(body, contentType) +} + +/** True when a zero-sat output is provably unspendable and therefore minable. */ +export function isProvablyUnspendable(script: Script): boolean { + const b = script.toBinary() + return b.length >= 2 && b[0] === OP.OP_FALSE && b[1] === OP.OP_RETURN +} diff --git a/src/seal.ts b/src/seal.ts new file mode 100644 index 0000000..f0ded25 --- /dev/null +++ b/src/seal.ts @@ -0,0 +1,34 @@ +import { pushDropCustomInstructions, pushDropLock } from '@1sat/actions' +import type { WalletInterface } from '@bsv/sdk' +import { + type CommitToken, + commitTokenFields, + GIB_PROTOCOL, + gibKeyId, +} from './token.ts' + +type SealWallet = Pick + +/** Sealed commit-head lock: fields signed under keyID = the root outpoint. */ +export async function sealCommitLock(wallet: SealWallet, token: CommitToken) { + return pushDropLock( + wallet as WalletInterface, + { + fields: commitTokenFields(token), + protocolID: GIB_PROTOCOL, + keyID: gibKeyId(token.root), + counterparty: 'anyone', + forSelf: true, + }, + { includeSignature: true }, + ) +} + +/** Wallet customInstructions needed to spend a commit head later. */ +export function commitHeadCustomInstructions(root: string): string { + return pushDropCustomInstructions({ + protocolID: GIB_PROTOCOL, + keyID: gibKeyId(root), + counterparty: 'anyone', + }) +} diff --git a/src/token.ts b/src/token.ts new file mode 100644 index 0000000..529081e --- /dev/null +++ b/src/token.ts @@ -0,0 +1,121 @@ +import { pushDropDecode } from '@1sat/actions' +import { LockingScript, 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 + /** Outpoint of the published root directory this head points at. */ + root: string + identityPubkey: string + /** + * The head this one branched from, or merged in: empty on an ordinary + * push, which has only the head it spends. + * + * A head's parents mirror its commit's parents by construction. The + * spend is the first parent's lineage; this field is the other one — + * set on a branch's first head (naming the head it forked from) and on + * a merge (naming a head publishing the second parent). + */ + branchedFrom: string +} + +/** + * Fields on the wire, in order. The signature pushDropLock appends makes + * the seventh. + */ +export const COMMIT_TOKEN_FIELDS = 6 + +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}` +} + +/** + * Action labels are a fixed vocabulary: BRC-100 wallets gate each distinct + * label string as its own permission (`action label