diff --git a/README.md b/README.md index 8842d58..ab4f554 100644 --- a/README.md +++ b/README.md @@ -13,36 +13,39 @@ > *Visita Interiora Terrae Rectificando Invenies Occultum Lapidem* -Gate GitHub PRs to contributors with on-chain [Groundwire](https://groundwire.network) identities. +Gate GitHub PRs to contributors with on-chain [Groundwire](https://groundwire.network) identities — and optionally require ecash payment per PR. Every commit is signed with the contributor's ship's Ed25519 networking key — the same key attested on-chain via a Groundwire inscription. A CI check verifies each signature against the signer's on-chain key by asking a Groundwire ship to look it up in Jael. +Maintainers can set a sats-per-PR price. Committers load their wallet with sats from a Cashu mint via Lightning, and the right number of ecash tokens are automatically included with each signature. The maintainer's ship NUT-03 swaps the tokens to verify their value before passing CI. + ## How it works ``` -contributor's ship GitHub Actions CI's ship - | | | - signs commit extracts sig from verifies sig - with Ed25519 gpgsig header, sends against signer's - networking key to CI ship for check on-chain pass - | | | - [ring] [workflow] [Jael] +contributor's ship GitHub Actions maintainer's ship + | | | + signs commit extracts sig from verifies sig + with Ed25519 gpgsig header, sends against signer's + networking key to CI ship for check on-chain pass + | | | + [ring + wallet] [workflow] [Jael + NUT-03] ``` -Three pieces: +Four pieces: -1. **`hooks/groundwire-sign`** — custom `gpg.program` that sends commit content to the contributor's ship for signing -2. **`desk/`** — Urbit `%vitriol` Gall agent that handles signing and verification -3. **`.github/workflows/groundwire-verify.yml`** — GitHub Action that gates PRs on valid Groundwire signatures +1. **`hooks/groundwire-sign`** — custom `gpg.program` that sends commit content to the contributor's ship for signing, fetches the maintainer's price, and includes ecash tokens if required +2. **`hooks/install.sh`** — one-line setup for contributors +3. **`desk/`** — Urbit `%vitriol` Gall agent that handles signing, verification, ecash wallet, and admin UI +4. **`.github/workflows/groundwire-verify.yml`** — GitHub Action that gates PRs on valid Groundwire signatures ## Ship setup The `%vitriol` desk needs to be installed on two ships: -- **Signing ship** — the contributor's local ship, signs commits -- **Verification ship** — a publicly reachable ship used by CI to verify signatures against on-chain keys +- **Committer ship** — the contributor's local ship, signs commits and holds ecash wallet +- **Maintainer ship** — a publicly reachable ship used by CI to verify signatures, receive ecash, and manage the ban list -Both run the same agent. The signing ship uses the `/sign` endpoint (needs access to its own private key via Jael). The CI ship uses `/verify-commit` (reads the signer's public key from its own Jael, populated by `%ord-watcher`). +Both run the same agent. The committer uses `/sign` (needs access to its own private key via Jael). The maintainer uses `/verify-commit` (reads the signer's public key from its own Jael, populated by `%ord-watcher`). ### Install the desk @@ -53,14 +56,10 @@ On each ship, in dojo: |mount %vitriol ``` -Then copy the desk files from this repo into the mounted directory (replace `` with the ship's pier path): +Copy the desk files into the mounted directory: ```bash -cp desk/app/vitriol.hoon /vitriol/app/vitriol.hoon -cp desk/lib/server.hoon /vitriol/lib/server.hoon -cp desk/sur/vitriol.hoon /vitriol/sur/vitriol.hoon -cp desk/desk.bill /vitriol/desk.bill -cp desk/sys.kelvin /vitriol/sys.kelvin +cp -r desk/* /vitriol/ ``` Back in dojo, commit and install: @@ -78,15 +77,55 @@ curl -s http:///vitriol/pubkey -H "Cookie: " You should see the ship's pass, life, and @p. +### Admin panel + +Visit `http:///vitriol/admin` (requires auth cookie) to: + +- **Committer settings:** configure a Cashu mint, load sats via Lightning, view wallet balance +- **Maintainer settings:** toggle payment requirement, set sats-per-PR price, manage ban list, view ecash encryption pubkey + +A landing page at `http:///vitriol` describes the app and lists all endpoints. + ### Endpoints +#### Signing & verification + +| Method | Path | Description | +|--------|------|-------------| +| POST | `/vitriol/sign` | Sign commit content with networking key. Accepts optional `sats_required` to include ecash tokens from wallet. | +| POST | `/vitriol/verify-commit` | Verify signature against signer's on-chain key. If ecash tokens are included with a `mint` URL, NUT-03 swaps them to verify value. Returns `verify_id` for polling. | +| GET | `/vitriol/verify-status/{id}` | Poll for async ecash verification result (pending/verified/failed). | + +#### Identity + | Method | Path | Description | |--------|------|-------------| | GET | `/vitriol/pubkey` | Ship's networking key from Jael | -| POST | `/vitriol/sign` | Sign commit content with networking key | -| POST | `/vitriol/verify-commit` | Verify signature against signer's on-chain key | +| GET | `/vitriol/ecash-pubkey` | Ship's Curve25519 encryption pubkey for receiving ecash | | GET | `/vitriol/check-id/~ship` | Check if a ship is attested on-chain | +#### Ecash & payment + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/vitriol/sats-per-pr` | Maintainer's price per PR in sats | +| GET | `/vitriol/banned` | List banned ships | +| POST | `/vitriol/ban` | Ban a ship (JSON: `{"ship":"~sampel"}`) | +| POST | `/vitriol/unban` | Unban a ship (JSON: `{"ship":"~sampel"}`) | + +#### Admin UI (form actions) + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/vitriol` | Landing page | +| GET | `/vitriol/admin` | Admin panel | +| POST | `/vitriol/admin/set-mint` | Set Cashu mint URL | +| POST | `/vitriol/admin/load-sats` | Request Lightning invoice to load wallet | +| POST | `/vitriol/admin/toggle-payment` | Toggle ecash payment requirement | +| POST | `/vitriol/admin/set-price` | Set sats-per-PR price | +| POST | `/vitriol/admin/ban` | Ban a ship (form) | +| POST | `/vitriol/admin/unban` | Unban a ship (form) | + ## Contributor setup ### Quick install @@ -95,7 +134,14 @@ You should see the ship's pass, life, and @p. ./hooks/install.sh /vitriol "" ``` -This configures git globally to sign all commits with your Groundwire key. +To also configure ecash payment to a maintainer: + +```bash +./hooks/install.sh /vitriol "" \ + --maintainer /vitriol "" +``` + +This configures git globally to sign all commits with your Groundwire key. The hook will automatically fetch the maintainer's price and include the right ecash tokens. ### Manual install @@ -104,10 +150,21 @@ git config --global gpg.program /path/to/hooks/groundwire-sign git config --global commit.gpgsign true git config --global groundwire.sign-endpoint /vitriol git config --global groundwire.sign-token "" + +# Optional: maintainer ecash +git config --global groundwire.maintainer-endpoint /vitriol +git config --global groundwire.maintainer-token "" ``` To configure per-repo instead of globally, drop the `--global` flag. +### Loading your wallet + +1. Open `http:///vitriol/admin` +2. Under **Committer > Wallet**, enter a Cashu mint URL and click **Set mint** +3. Enter the amount of sats to load and click **Get invoice** +4. Pay the Lightning invoice — the agent polls the mint and stores the tokens automatically + ### Test signing ```bash @@ -122,9 +179,46 @@ You should see a `gpgsig` header containing: signer:~your-ship pass: sig: +ecash-pubkey: +ecash-amount:100 +ecash-tokens:[{"amount":64,"id":"...","secret":"...","C":"..."},...] -----END GROUNDWIRE SIGNATURE----- ``` +The `ecash-*` fields are only present when a maintainer price is configured and the committer has tokens. + +## Maintainer setup + +### Requiring payment + +1. Open `http:///vitriol/admin` +2. Under **Maintainer > Ecash payment**, click **Enable** to require payment +3. Set the **sats per PR** price +4. Share your ship URL with contributors so they can configure the `--maintainer` flag + +### How token verification works + +When a committer includes ecash tokens in their signature: + +1. The CI workflow sends the tokens to the maintainer's `/verify-commit` endpoint +2. The maintainer's agent fetches the keyset keys from the Cashu mint +3. It performs a NUT-03 swap — exchanging the committer's tokens for fresh ones +4. If the swap succeeds, the tokens are real and the value is confirmed +5. The swapped tokens are stored in the maintainer's wallet +6. The CI polls `/verify-status/{id}` until the swap completes + +This ensures the maintainer never accepts invalid or already-spent tokens. + +### Token selection (committer side) + +When the hook fetches a maintainer's `sats-per-pr` price, it passes `sats_required` to the committer's `/sign` endpoint. The agent selects tokens from the wallet using these rules: + +- Total must be **>= required** +- Total must be **<= 110% of required** (no more than 10% overpayment) +- If no valid combination exists, the sign request fails with an error + +This prevents accidentally overpaying while accommodating Cashu's power-of-2 denominations. + ## CI setup ### Repository secrets @@ -144,7 +238,8 @@ Copy `.github/workflows/groundwire-verify.yml` into your repo. It runs on every 2. Extracts the Groundwire signature from each commit's `gpgsig` header 3. Sends the signature, signer @p, and commit payload to the CI ship's `/vitriol/verify-commit` 4. The CI ship looks up the signer's on-chain public key in Jael and verifies the Ed25519 signature -5. Fails the check and comments on the PR if any commit is unsigned or unverified +5. If ecash tokens are present, the CI ship NUT-03 swaps them at the mint to verify value +6. Fails the check and comments on the PR if any commit is unsigned, unverified, or underpaid ### Requirements @@ -152,12 +247,42 @@ Copy `.github/workflows/groundwire-verify.yml` into your repo. It runs on every - The CI ship must be publicly reachable from GitHub Actions runners - The signer must have a Groundwire identity attested on-chain -## Cryptography +## Architecture + +### Desk contents + +``` +desk/ + app/vitriol.hoon — main Gall agent (signing, verification, wallet, HTTP) + lib/vitriol-ui.hoon — Sail admin UI and landing page + lib/cashu.hoon — Cashu wallet operations (NUT-00/03/04/05 BDHKE) + lib/server.hoon — HTTP response helpers + sur/vitriol.hoon — type definitions (cashu-proof, pending-mint-quote, pending-verify) + sys.kelvin — compatible with kelvin 408 and 409 +``` + +### Agent state + +The agent maintains: + +- **ecash-key** — Curve25519 keypair for ecash encryption +- **banned** — set of @p's rejected during verification +- **require-payment** — whether ecash payment is required for verify-commit +- **sats-per-pr** — price per PR in sats (optional) +- **mint** — configured Cashu mint URL (optional) +- **wallet** — map of mint URL to list of cashu proofs +- **mint-keysets** — cached keyset keys from mints +- **pending-mints** — in-flight Lightning invoice → token flows +- **pending-verifies** — in-flight NUT-03 swap verifications + +### Cryptography Commits are signed with the ship's Ed25519 networking key — the same key stored in Jael and attested on-chain via a Groundwire Bitcoin inscription. - **Signing:** the agent extracts the 32-byte signing seed from the ship's `ring` (Jael `/vein` scry) and signs with `sign-octs:ed:crypto` -- **Verification:** the agent extracts the 32-byte signing public key from the signer's `pass` (Jael `/deed` scry) and verifies with `veri-octs:ed:crypto` +- **Verification:** the agent extracts the 32-byte signing public key from the signer's `pass` (Jael `/pynt` scry) and verifies with `veri-octs:ed:crypto` +- **Ecash encryption:** Curve25519 keypair via `scalarmult-base:ed:crypto` for future DH key exchange (`shar:ed:crypto`) +- **Token operations:** BDHKE (Blind Diffie-Hellman Key Exchange) per Cashu NUT-00, using secp256k1 via `fo` modular arithmetic Key format (Suite B): @@ -166,4 +291,10 @@ pass: [1 byte 'b'] [32 bytes sgn pubkey] [32 bytes cry pubkey] ring: [1 byte 'B'] [32 bytes sgn seed] [32 bytes cry seed] ``` -No key material is generated or stored by the agent — it uses whatever Jael has. +## Security + +- All HTTP endpoints require Eyre authentication (403 for unauthenticated requests) +- No key material is generated or stored by the agent for signing — it uses whatever Jael has +- Ecash tokens are verified via NUT-03 swap before acceptance (prevents double-spending and invalid tokens) +- The ban list is checked before signature verification +- Token selection enforces a 110% cap to prevent accidental overpayment diff --git a/desk/app/vitriol.hoon b/desk/app/vitriol.hoon index 7eff96d..0e3a034 100644 --- a/desk/app/vitriol.hoon +++ b/desk/app/vitriol.hoon @@ -1,21 +1,37 @@ :: /app/vitriol/hoon -:: Groundwire for GitHub — commit signing & on-chain identity verification +:: Groundwire for GitHub — commit signing, verification, and ecash payment :: -:: Two modes: -:: Signer (committer's ship): POST /vitriol/sign -:: Verifier (CI's ship): POST /vitriol/verify-commit +:: Roles: +:: Committer: POST /vitriol/sign — signs commits with Ed25519 networking key +:: Maintainer: POST /vitriol/verify-commit — verifies signatures against on-chain key :: -:: The signer signs commit content with the ship's Ed25519 networking -:: key — the same key attested on-chain via a Groundwire inscription. -:: The verifier checks the signature against the signer's on-chain -:: pass by scrying Jael (populated by ord-watcher). +:: Signing uses the ship's Ed25519 networking key — the same key attested +:: on-chain via a Groundwire inscription. Verification checks the signature +:: against the signer's on-chain pass by scrying Jael (populated by +:: ord-watcher). :: -/+ default-agent, server +:: Ecash (optional): +:: Maintainers can set a sats-per-PR price. Committers configure a Cashu +:: mint and load sats via Lightning invoices. When signing, the agent +:: selects tokens from the wallet (>= required, <= 110%) and includes +:: them in the response. On verification, the maintainer NUT-03 swaps +:: the tokens at the mint to confirm their value before accepting. +:: +:: Admin UI at /vitriol/admin (Sail). Landing page at /vitriol. +:: All endpoints require Eyre authentication. +:: +/- *vitriol +/+ default-agent, server, vitriol-ui, cashu |% +$ card card:agent:gall +$ versioned-state $% state-0 state-1 + state-2 + state-3 + state-4 + state-5 + state-6 == +$ state-0 $: %0 @@ -27,6 +43,45 @@ $: %1 ~ == ++$ state-2 + $: %2 + ecash-key=(unit [sec=@ pub=@]) + == ++$ state-3 + $: %3 + ecash-key=(unit [sec=@ pub=@]) + banned=(set @p) + == ++$ state-4 + $: %4 + ecash-key=(unit [sec=@ pub=@]) + banned=(set @p) + require-payment=? + == ++$ state-5 + $: %5 + ecash-key=(unit [sec=@ pub=@]) + banned=(set @p) + require-payment=? + mint=(unit @t) + wallet=(map @t (list cashu-proof)) + mint-keysets=(map @t (map @ud @t)) + pending-mints=(map @t pending-mint-quote) + == ++$ state-6 + $: %6 + ecash-key=(unit [sec=@ pub=@]) + banned=(set @p) + require-payment=? + sats-per-pr=(unit @ud) + mint=(unit @t) + wallet=(map @t (list cashu-proof)) + mint-keysets=(map @t (map @ud @t)) + pending-mints=(map @t pending-mint-quote) + pending-verifies=(map @t pending-verify) + == +:: +++ ca cashu :: ++ to-hex |= [width=@ val=@] @@ -82,9 +137,84 @@ == ?. ?=(%& -.result) ~ `;;(@ p.result) +++ gen-ecash-key + |= eny=@uvJ + ^- [sec=@ pub=@] + =/ sec=@ (end [3 32] eny) + =/ pub=@ (scalarmult-base:ed:crypto sec) + [sec pub] +:: +++ wallet-balance + |= w=(map @t (list cashu-proof)) + ^- @ud + %- ~(rep by w) + |= [[mint=@t proofs=(list cashu-proof)] acc=@ud] + (add acc (roll proofs |=([p=cashu-proof a=@ud] (add a amount.p)))) +:: +++ parse-form-field + |= [pairs=(list [@t @t]) field=@t] + ^- (unit @t) + =/ matches (skim pairs |=([k=@t *] =(k field))) + ?~ matches ~ + `+.i.matches +:: +:: Select proofs from wallet totaling >= required but <= 110% of required. +:: Returns (unit [selected remaining]) where selected are the proofs to spend +:: and remaining is the updated wallet. +:: Fails (returns ~) if no valid selection exists. +:: +++ select-proofs + |= [w=(map @t (list cashu-proof)) required=@ud] + ^- (unit [selected=(list cashu-proof) remaining=(map @t (list cashu-proof))]) + =/ max=@ud (div (mul required 11) 10) + :: flatten all proofs with their mint + =/ all=(list [mint=@t proof=cashu-proof]) + %- zing + %+ turn ~(tap by w) + |= [m=@t ps=(list cashu-proof)] + (turn ps |=(p=cashu-proof [m p])) + :: sort by amount descending for greedy selection + =/ sorted=(list [mint=@t proof=cashu-proof]) + %+ sort all + |= [a=[mint=@t proof=cashu-proof] b=[mint=@t proof=cashu-proof]] + (gth amount.proof.a amount.proof.b) + :: greedy: take largest proofs until we meet the requirement + =/ selected=(list [mint=@t proof=cashu-proof]) ~ + =/ total=@ud 0 + =/ rest=(list [mint=@t proof=cashu-proof]) sorted + |- + ?: (gte total required) + :: we have enough — check 110% cap + ?: (gth total max) ~ + :: build results + =/ sel=(list cashu-proof) (turn selected |=([m=@t p=cashu-proof] p)) + :: rebuild wallet minus selected + =/ new-wallet=(map @t (list cashu-proof)) w + |- ^- (unit [selected=(list cashu-proof) remaining=(map @t (list cashu-proof))]) + ?~ selected + `[sel new-wallet] + =/ m=@t mint.i.selected + =/ p=cashu-proof proof.i.selected + =/ existing=(list cashu-proof) (~(gut by new-wallet) m ~) + =/ updated=(list cashu-proof) + %+ skip existing + |= e=cashu-proof + &(=(secret.e secret.p) =(amount.e amount.p)) + =. new-wallet + ?: =(~ updated) + (~(del by new-wallet) m) + (~(put by new-wallet) m updated) + $(selected t.selected) + ?~ rest ~ + =/ candidate i.rest + =/ new-total (add total amount.proof.candidate) + :: skip if adding this proof would push past 110% when we're already >= required + ?: &((gte (add total amount.proof.candidate) required) (gth new-total max)) + $(rest t.rest) + $(selected [candidate selected], total new-total, rest t.rest) -- ^- agent:gall -=| state-1 +=| state-6 =* state - |_ =bowl:gall +* this . @@ -92,7 +222,18 @@ :: ++ on-init ^- (quip card _this) - :_ this + =/ kp (gen-ecash-key eny.bowl) + :_ %= this + ecash-key `kp + banned *(set @p) + require-payment %.n + sats-per-pr ~ + mint ~ + wallet *(map @t (list cashu-proof)) + mint-keysets *(map @t (map @ud @t)) + pending-mints *(map @t pending-mint-quote) + pending-verifies *(map @t pending-verify) + == :~ [%pass /eyre/connect %arvo %e %connect [~ /vitriol] dap.bowl] == :: @@ -102,9 +243,93 @@ |= =vase ^- (quip card _this) =/ old !<(versioned-state vase) + =/ eyre-card=card [%pass /eyre/connect %arvo %e %connect [~ /vitriol] dap.bowl] + =/ empty-wallet *(map @t (list cashu-proof)) + =/ empty-keysets *(map @t (map @ud @t)) + =/ empty-pending *(map @t pending-mint-quote) + =/ empty-verifies *(map @t pending-verify) ?- -.old - %1 `this(state old) - %0 `this(state *state-1) + %6 [~[eyre-card] this(state old)] + %5 + :_ %= this + ecash-key ecash-key.old + banned banned.old + require-payment require-payment.old + sats-per-pr ~ + mint mint.old + wallet wallet.old + mint-keysets mint-keysets.old + pending-mints pending-mints.old + pending-verifies empty-verifies + == + ~[eyre-card] + %4 + :_ %= this + ecash-key ecash-key.old + banned banned.old + require-payment require-payment.old + sats-per-pr ~ + mint ~ + wallet empty-wallet + mint-keysets empty-keysets + pending-mints empty-pending + pending-verifies empty-verifies + == + ~[eyre-card] + %3 + :_ %= this + ecash-key ecash-key.old + banned banned.old + require-payment %.n + sats-per-pr ~ + mint ~ + wallet empty-wallet + mint-keysets empty-keysets + pending-mints empty-pending + pending-verifies empty-verifies + == + ~[eyre-card] + %2 + :_ %= this + ecash-key ecash-key.old + banned *(set @p) + require-payment %.n + sats-per-pr ~ + mint ~ + wallet empty-wallet + mint-keysets empty-keysets + pending-mints empty-pending + pending-verifies empty-verifies + == + ~[eyre-card] + %1 + =/ kp (gen-ecash-key eny.bowl) + :_ %= this + ecash-key `kp + banned *(set @p) + require-payment %.n + sats-per-pr ~ + mint ~ + wallet empty-wallet + mint-keysets empty-keysets + pending-mints empty-pending + pending-verifies empty-verifies + == + ~[eyre-card] + %0 + =/ kp (gen-ecash-key eny.bowl) + :_ %= this + ecash-key `kp + banned *(set @p) + require-payment %.n + sats-per-pr ~ + mint ~ + wallet empty-wallet + mint-keysets empty-keysets + pending-mints empty-pending + pending-verifies empty-verifies + == + ~[eyre-card] == :: ++ on-poke @@ -123,6 +348,177 @@ :_ this (give-simple-payload:app:server eyre-id not-found:gen:server) :: + :: GET /vitriol — landing page + :: + [%vitriol ~] + :_ this + (html-response:vitriol-ui eyre-id (render-home:vitriol-ui our.bowl)) + :: + :: GET /vitriol/admin — admin UI + :: + [%vitriol %admin ~] + :_ this + %: html-response:vitriol-ui + eyre-id + %: render-admin:vitriol-ui + our.bowl + ecash-key + banned + require-payment + sats-per-pr + mint + wallet + pending-mints + to-hex + == + == + :: + :: POST /vitriol/admin/ban — ban form action + :: + [%vitriol %admin %ban ~] + ?. =(meth %'POST') + :_ this + (redirect-response:vitriol-ui eyre-id '/vitriol/admin') + =/ body=@t (crip (trip q:(need body.request.req))) + =/ pairs (rush body yquy:de-purl:html) + ?~ pairs + :_ this + (redirect-response:vitriol-ui eyre-id '/vitriol/admin') + =/ ship-val (parse-form-field u.pairs 'ship') + ?~ ship-val + :_ this + (redirect-response:vitriol-ui eyre-id '/vitriol/admin') + =/ who (slav %p u.ship-val) + :_ this(banned (~(put in banned) who)) + (redirect-response:vitriol-ui eyre-id '/vitriol/admin') + :: + :: POST /vitriol/admin/unban — unban form action + :: + [%vitriol %admin %unban ~] + ?. =(meth %'POST') + :_ this + (redirect-response:vitriol-ui eyre-id '/vitriol/admin') + =/ body=@t (crip (trip q:(need body.request.req))) + =/ pairs (rush body yquy:de-purl:html) + ?~ pairs + :_ this + (redirect-response:vitriol-ui eyre-id '/vitriol/admin') + =/ ship-val (parse-form-field u.pairs 'ship') + ?~ ship-val + :_ this + (redirect-response:vitriol-ui eyre-id '/vitriol/admin') + =/ who (slav %p u.ship-val) + :_ this(banned (~(del in banned) who)) + (redirect-response:vitriol-ui eyre-id '/vitriol/admin') + :: + :: POST /vitriol/admin/toggle-payment — toggle require-payment + :: + [%vitriol %admin %toggle-payment ~] + :_ this(require-payment !require-payment) + (redirect-response:vitriol-ui eyre-id '/vitriol/admin') + :: + :: POST /vitriol/admin/set-price — set sats per PR + :: + [%vitriol %admin %set-price ~] + ?. =(meth %'POST') + :_ this + (redirect-response:vitriol-ui eyre-id '/vitriol/admin') + =/ body=@t (crip (trip q:(need body.request.req))) + =/ pairs (rush body yquy:de-purl:html) + ?~ pairs + :_ this + (redirect-response:vitriol-ui eyre-id '/vitriol/admin') + =/ price-val (parse-form-field u.pairs 'price') + ?~ price-val + :_ this(sats-per-pr ~) + (redirect-response:vitriol-ui eyre-id '/vitriol/admin') + ?: =('' u.price-val) + :_ this(sats-per-pr ~) + (redirect-response:vitriol-ui eyre-id '/vitriol/admin') + =/ price=@ud + (roll (trip u.price-val) |=([c=@ a=@ud] (add (mul a 10) (sub c '0')))) + :_ this(sats-per-pr ?:(=(0 price) ~ `price)) + (redirect-response:vitriol-ui eyre-id '/vitriol/admin') + :: + :: POST /vitriol/admin/set-mint — set mint URL + :: + [%vitriol %admin %set-mint ~] + ?. =(meth %'POST') + :_ this + (redirect-response:vitriol-ui eyre-id '/vitriol/admin') + =/ body=@t (crip (trip q:(need body.request.req))) + =/ pairs (rush body yquy:de-purl:html) + ?~ pairs + :_ this + (redirect-response:vitriol-ui eyre-id '/vitriol/admin') + =/ mint-val (parse-form-field u.pairs 'mint') + ?~ mint-val + :_ this(mint ~) + (redirect-response:vitriol-ui eyre-id '/vitriol/admin') + ?: =('' u.mint-val) + :_ this(mint ~) + (redirect-response:vitriol-ui eyre-id '/vitriol/admin') + :_ this(mint `(crip (clean-mint-url:ca u.mint-val))) + (redirect-response:vitriol-ui eyre-id '/vitriol/admin') + :: + :: POST /vitriol/admin/load-sats — request lightning invoice from mint + :: + [%vitriol %admin %load-sats ~] + ?. =(meth %'POST') + :_ this + (redirect-response:vitriol-ui eyre-id '/vitriol/admin') + ?~ mint + :_ this + (redirect-response:vitriol-ui eyre-id '/vitriol/admin') + =/ body=@t (crip (trip q:(need body.request.req))) + =/ pairs (rush body yquy:de-purl:html) + ?~ pairs + :_ this + (redirect-response:vitriol-ui eyre-id '/vitriol/admin') + =/ amt-val (parse-form-field u.pairs 'amount') + ?~ amt-val + :_ this + (redirect-response:vitriol-ui eyre-id '/vitriol/admin') + =/ amount=@ud + (roll (trip u.amt-val) |=([c=@ a=@ud] (add (mul a 10) (sub c '0')))) + ?: =(0 amount) + :_ this + (redirect-response:vitriol-ui eyre-id '/vitriol/admin') + =/ mint-clean=tape (clean-mint-url:ca u.mint) + =/ mint-cord=@t (crip mint-clean) + :: check for cached keyset + =/ keyset-id=@t + =/ ks ~(tap by mint-keysets) + ?~ ks '' + -.i.ks + :: generate nonce for this operation + =/ nonce=@t (scot %uv (sham [eny.bowl now.bowl])) + =. pending-mints + %+ ~(put by pending-mints) nonce + :* mint-cord + '' + '' + amount + keyset-id + *@da + ?:(=('' keyset-id) %fetch-keys %quote) + *(list @t) + *(list @) + == + ?: =('' keyset-id) + :: need to fetch keysets first + =/ keys-url=@t (crip (weld mint-clean "/v1/keysets")) + :_ this + :~ [%pass /iris/mint-keys/[nonce] %arvo %i %request [%'GET' keys-url ~ ~] *outbound-config:iris] + == + :: have keyset, go straight to quote + =/ quote-body=@t (en:json:html (build-mint-quote-request:ca amount 'sat')) + =/ quote-octs=octs [(met 3 quote-body) quote-body] + =/ quote-url=@t (crip (weld mint-clean "/v1/mint/quote/bolt11")) + :_ this + :~ [%pass /iris/mint-quote/[nonce] %arvo %i %request [%'POST' quote-url ~[['content-type' 'application/json']] `quote-octs] *outbound-config:iris] + == + :: :: GET /vitriol/pubkey — return this ship's on-chain networking key :: [%vitriol %pubkey ~] @@ -139,6 +535,33 @@ :_ this (give-simple-payload:app:server eyre-id (json-response:gen:server result)) :: + :: GET /vitriol/ecash-pubkey — return this ship's ecash encryption pubkey + :: + [%vitriol %ecash-pubkey ~] + =/ result=json + ?~ ecash-key + (pairs:enjs:format ~[['configured' b+%.n] ['error' s+'ecash keypair not generated']]) + %- pairs:enjs:format + :~ ['configured' b+%.y] + ['pubkey' s+(to-hex 64 pub.u.ecash-key)] + ['ship' s+(scot %p our.bowl)] + == + :_ this + (give-simple-payload:app:server eyre-id (json-response:gen:server result)) + :: + :: GET /vitriol/sats-per-pr — return the maintainer's price + :: + [%vitriol %sats-per-pr ~] + =/ result=json + ?~ sats-per-pr + (pairs:enjs:format ~[['configured' b+%.n]]) + %- pairs:enjs:format + :~ ['configured' b+%.y] + ['sats' (numb:enjs:format u.sats-per-pr)] + == + :_ this + (give-simple-payload:app:server eyre-id (json-response:gen:server result)) + :: :: POST /vitriol/sign — sign commit content with networking key :: [%vitriol %sign ~] @@ -146,7 +569,6 @@ =/ err=json (pairs:enjs:format ['error' s+'POST required']~) :_ this (give-simple-payload:app:server eyre-id (json-response:gen:server err)) - :: get our deed and ring from Jael =/ deed (deed-safe bowl our.bowl) ?~ deed =/ err=json (pairs:enjs:format ['error' s+'no keys in Jael']~) @@ -157,24 +579,64 @@ =/ err=json (pairs:enjs:format ['error' s+'cannot read private key from Jael']~) :_ this (give-simple-payload:app:server eyre-id (json-response:gen:server err)) - :: extract Ed25519 signing seed from ring - :: ring format (suite B): 1 byte 'B' + 32 bytes sgn-seed + 32 bytes cry-seed =/ sgn-seed (end 8 (rsh 3 u.ring)) =/ jon (need (de:json:html q:(need body.request.req))) - =/ content (so:dejs:format (~(got by ((om:dejs:format same) jon)) 'content')) + =/ fields ((om:dejs:format same) jon) + =/ content (so:dejs:format (~(got by fields) 'content')) =/ msg=octs [(met 3 content) content] =/ sig=@ (sign-octs:ed:crypto msg sgn-seed) + :: check if ecash tokens are requested + =/ sats-req=(unit @ud) + =/ sr (~(get by fields) 'sats_required') + ?~ sr ~ + ?. ?=([%n *] u.sr) ~ + =/ n=@ud (roll (trip p.u.sr) |=([c=@ a=@ud] (add (mul a 10) (sub c '0')))) + ?:(=(0 n) ~ `n) + ?~ sats-req + :: no payment requested — plain signature + =/ result=json + %- pairs:enjs:format + :~ ['signature' s+(to-hex 128 sig)] + ['signer_id' s+(scot %p our.bowl)] + ['pass' s+(to-hex 130 pass.u.deed)] + == + :_ this + (give-simple-payload:app:server eyre-id (json-response:gen:server result)) + :: payment requested — select proofs from wallet + =/ selection (select-proofs wallet u.sats-req) + ?~ selection + =/ err=json + %- pairs:enjs:format + :~ ['error' s+'insufficient wallet balance or no valid token selection within 110% of required'] + ['sats_required' (numb:enjs:format u.sats-req)] + ['wallet_balance' (numb:enjs:format (wallet-balance wallet))] + == + :_ this + (give-simple-payload:app:server eyre-id (json-response:gen:server err)) + =/ selected=(list cashu-proof) selected.u.selection + =/ token-total=@ud (roll selected |=([p=cashu-proof a=@ud] (add a amount.p))) + =/ tokens-json=json + :- %a + %+ turn selected + |= p=cashu-proof + %- pairs:enjs:format + :~ ['amount' (numb:enjs:format amount.p)] + ['id' s+id.p] + ['secret' s+secret.p] + ['C' s+c.p] + == =/ result=json %- pairs:enjs:format :~ ['signature' s+(to-hex 128 sig)] ['signer_id' s+(scot %p our.bowl)] ['pass' s+(to-hex 130 pass.u.deed)] + ['ecash_tokens' tokens-json] + ['ecash_amount' (numb:enjs:format token-total)] == - :_ this + :_ this(wallet remaining.u.selection) (give-simple-payload:app:server eyre-id (json-response:gen:server result)) :: :: POST /vitriol/verify-commit — verify signature against on-chain key - :: Body: {"signer":"~ship", "signature":"hex...", "payload":"..."} :: [%vitriol %verify-commit ~] ?. =(meth %'POST') @@ -186,9 +648,16 @@ =/ signer-cord (so:dejs:format (~(got by fields) 'signer')) =/ sig-hex (so:dejs:format (~(got by fields) 'signature')) =/ payload (so:dejs:format (~(got by fields) 'payload')) - :: resolve signer =/ who (slav %p signer-cord) - :: scry Jael for signer's on-chain deed + ?: (~(has in banned) who) + =/ result=json + %- pairs:enjs:format + :~ ['verified' b+%.n] + ['signer' s+signer-cord] + ['error' s+'signer is banned'] + == + :_ this + (give-simple-payload:app:server eyre-id (json-response:gen:server result)) =/ deed (deed-safe bowl who) ?~ deed =/ result=json @@ -208,28 +677,155 @@ == :_ this (give-simple-payload:app:server eyre-id (json-response:gen:server result)) - :: extract Ed25519 signing pubkey from on-chain pass - :: pass format (suite b): 1 byte 'b' + 32 bytes sgn-pub + 32 bytes cry-pub =/ sgn-pub (end 8 (rsh 3 pass.u.deed)) =/ sig=@ (from-hex sig-hex) =/ msg=octs [(met 3 payload) payload] =/ valid=? (veri-octs:ed:crypto sig msg sgn-pub) - =/ result=json - ?: valid + ?. valid + =/ result=json + %- pairs:enjs:format + :~ ['verified' b+%.n] + ['signer' s+signer-cord] + ['error' s+'signature does not match on-chain key'] + == + :_ this + (give-simple-payload:app:server eyre-id (json-response:gen:server result)) + :: signature valid — parse ecash tokens if present + =/ incoming-tokens=(list cashu-proof) + =/ tok (~(get by fields) 'ecash_tokens') + ?~ tok ~ + ?. ?=([%a *] u.tok) ~ + %+ murn p.u.tok + |= t=json + ^- (unit cashu-proof) + ?. ?=([%o *] t) ~ + =/ a (~(get by p.t) 'amount') + =/ i (~(get by p.t) 'id') + =/ s (~(get by p.t) 'secret') + =/ c (~(get by p.t) 'C') + ?~ a ~ + ?~ i ~ + ?~ s ~ + ?~ c ~ + =/ amt=@ud + ?. ?=([%n *] u.a) 0 + (roll (trip p.u.a) |=([ch=@ ac=@ud] (add (mul ac 10) (sub ch '0')))) + ?. ?=([%s *] u.i) ~ + ?. ?=([%s *] u.s) ~ + ?. ?=([%s *] u.c) ~ + `[amt p.u.i p.u.s p.u.c] + =/ token-total=@ud + (roll incoming-tokens |=([p=cashu-proof a=@ud] (add a amount.p))) + =/ mint-url-cord=@t + =/ m (~(get by fields) 'mint') + ?~ m '' + ?. ?=([%s *] u.m) '' + p.u.m + :: check payment requirement + ?: &(require-payment ?=(^ sats-per-pr) (lth token-total u.sats-per-pr)) + =/ result=json + %- pairs:enjs:format + :~ ['verified' b+%.n] + ['signer' s+signer-cord] + ['error' s+'insufficient ecash payment'] + ['sats_required' (numb:enjs:format u.sats-per-pr)] + ['sats_received' (numb:enjs:format token-total)] + == + :_ this + (give-simple-payload:app:server eyre-id (json-response:gen:server result)) + :: no tokens — just return verified + ?: =(~ incoming-tokens) + =/ result=json %- pairs:enjs:format :~ ['verified' b+%.y] ['signer' s+signer-cord] ['life' (numb:enjs:format life.u.deed)] == + :_ this + (give-simple-payload:app:server eyre-id (json-response:gen:server result)) + :: tokens present — NUT-03 swap to verify value before accepting + :: need mint URL to swap + ?: =('' mint-url-cord) + =/ result=json + %- pairs:enjs:format + :~ ['verified' b+%.n] + ['signer' s+signer-cord] + ['error' s+'ecash tokens included but no mint URL provided'] + == + :_ this + (give-simple-payload:app:server eyre-id (json-response:gen:server result)) + :: get keyset id from first token + =/ keyset-id=@t id:(snag 0 incoming-tokens) + =/ verify-id=@t (scot %uv (sham [eny.bowl now.bowl 'verify'])) + =/ mint-clean=tape (clean-mint-url:ca mint-url-cord) + =. pending-verifies + %+ ~(put by pending-verifies) verify-id + :* who + life.u.deed + (crip mint-clean) + incoming-tokens + token-total + %fetch-keys + keyset-id + *(list @t) + *(list @) + %pending + '' + == + :: fetch keyset keys for this keyset id + =/ keys-url=@t (crip ;:(weld mint-clean "/v1/keys/" (trip keyset-id))) + =/ result=json + %- pairs:enjs:format + :~ ['status' s+'pending'] + ['verify_id' s+verify-id] + ['signer' s+signer-cord] + ['message' s+'verifying ecash tokens via NUT-03 swap'] + == + =/ http-cards=(list card) + (give-simple-payload:app:server eyre-id (json-response:gen:server result)) + =/ iris-card=card + [%pass /iris/verify-keys/[verify-id] %arvo %i %request [%'GET' keys-url ~ ~] *outbound-config:iris] + :_ this + (snoc http-cards iris-card) + :: + :: GET /vitriol/verify-status/[id] — poll for swap verification result + :: + [%vitriol %verify-status *] + ?~ t.t.site.rl + :_ this + (give-simple-payload:app:server eyre-id not-found:gen:server) + =/ vid=@t i.t.t.site.rl + =/ pv (~(get by pending-verifies) vid) + ?~ pv + =/ result=json + (pairs:enjs:format ~[['error' s+'unknown verify_id']]) + :_ this + (give-simple-payload:app:server eyre-id (json-response:gen:server result)) + =/ result=json + ?: =(%pending result.u.pv) + %- pairs:enjs:format + :~ ['status' s+'pending'] + ['verify_id' s+vid] + == + ?: =(%verified result.u.pv) + %- pairs:enjs:format + :~ ['verified' b+%.y] + ['signer' s+(scot %p signer.u.pv)] + ['life' (numb:enjs:format life.u.pv)] + ['ecash_received' (numb:enjs:format token-total.u.pv)] + == %- pairs:enjs:format :~ ['verified' b+%.n] - ['signer' s+signer-cord] - ['error' s+'signature does not match on-chain key'] + ['signer' s+(scot %p signer.u.pv)] + ['error' s+error.u.pv] == + :: clean up completed verifications + =? pending-verifies !=(result.u.pv %pending) + (~(del by pending-verifies) vid) :_ this (give-simple-payload:app:server eyre-id (json-response:gen:server result)) :: - :: GET /vitriol/check-id/~ship — check if @p has on-chain Groundwire ID + :: GET /vitriol/check-id/~ship :: [%vitriol %check-id *] ?~ t.t.site.rl @@ -258,6 +854,56 @@ == :_ this (give-simple-payload:app:server eyre-id (json-response:gen:server result)) + :: + :: POST /vitriol/ban — JSON API + :: + [%vitriol %ban ~] + ?. =(meth %'POST') + =/ err=json (pairs:enjs:format ['error' s+'POST required']~) + :_ this + (give-simple-payload:app:server eyre-id (json-response:gen:server err)) + =/ jon (need (de:json:html q:(need body.request.req))) + =/ ship-cord (so:dejs:format (~(got by ((om:dejs:format same) jon)) 'ship')) + =/ who (slav %p ship-cord) + =/ result=json + %- pairs:enjs:format + :~ ['banned' b+%.y] + ['ship' s+ship-cord] + == + :_ this(banned (~(put in banned) who)) + (give-simple-payload:app:server eyre-id (json-response:gen:server result)) + :: + :: POST /vitriol/unban — JSON API + :: + [%vitriol %unban ~] + ?. =(meth %'POST') + =/ err=json (pairs:enjs:format ['error' s+'POST required']~) + :_ this + (give-simple-payload:app:server eyre-id (json-response:gen:server err)) + =/ jon (need (de:json:html q:(need body.request.req))) + =/ ship-cord (so:dejs:format (~(got by ((om:dejs:format same) jon)) 'ship')) + =/ who (slav %p ship-cord) + =/ result=json + %- pairs:enjs:format + :~ ['unbanned' b+%.y] + ['ship' s+ship-cord] + == + :_ this(banned (~(del in banned) who)) + (give-simple-payload:app:server eyre-id (json-response:gen:server result)) + :: + :: GET /vitriol/banned — list all banned ships + :: + [%vitriol %banned ~] + =/ ships=(list @p) ~(tap in banned) + =/ result=json + %- pairs:enjs:format + :~ ['count' (numb:enjs:format (lent ships))] + :- 'ships' + :- %a + (turn ships |=(s=@p s+(scot %p s))) + == + :_ this + (give-simple-payload:app:server eyre-id (json-response:gen:server result)) == == :: @@ -283,6 +929,28 @@ ['pass' s+(to-hex 130 pass.u.deed)] ['life' (numb:enjs:format life.u.deed)] == + :: + [%x %ecash-pubkey %json ~] + :- ~ :- ~ :- %json + !> ^- json + ?~ ecash-key + (pairs:enjs:format ['configured' b+%.n]~) + %- pairs:enjs:format + :~ ['configured' b+%.y] + ['pubkey' s+(to-hex 64 pub.u.ecash-key)] + ['ship' s+(scot %p our.bowl)] + == + :: + [%x %banned %json ~] + =/ ships=(list @p) ~(tap in banned) + :- ~ :- ~ :- %json + !> ^- json + %- pairs:enjs:format + :~ ['count' (numb:enjs:format (lent ships))] + :- 'ships' + :- %a + (turn ships |=(s=@p s+(scot %p s))) + == == :: ++ on-agent on-agent:def @@ -292,6 +960,447 @@ ^- (quip card _this) ?+ wire (on-arvo:def wire sign-arvo) [%eyre *] `this + :: + :: -- Mint flow: fetch keyset list -- + :: + [%iris %mint-keys @ ~] + =/ nonce=@t i.t.t.wire + =/ pending (~(get by pending-mints) nonce) + ?~ pending + `this + ?. ?=([%iris %http-response *] sign-arvo) + =. pending-mints (~(del by pending-mints) nonce) + `this + =/ =client-response:iris client-response.sign-arvo + ?. ?=([%finished *] client-response) `this + =/ response=response-header:http response-header.client-response + =/ body=(unit octs) ?~(full-file.client-response ~ `data.u.full-file.client-response) + ?. =(200 status-code.response) + ~& >>> [%mint-keys-rejected status-code.response] + =. pending-mints (~(del by pending-mints) nonce) + `this + ?~ body + =. pending-mints (~(del by pending-mints) nonce) + `this + =/ resp-json (de:json:html q.u.body) + ?~ resp-json + =. pending-mints (~(del by pending-mints) nonce) + `this + =/ jon u.resp-json + ?. ?=([%o *] jon) + =. pending-mints (~(del by pending-mints) nonce) + `this + =/ ks (~(get by p.jon) 'keysets') + ?~ ks + =. pending-mints (~(del by pending-mints) nonce) + `this + ?. ?=([%a *] u.ks) + =. pending-mints (~(del by pending-mints) nonce) + `this + :: find first active keyset with unit=sat + =/ matching=(list @t) + %+ murn p.u.ks + |= item=json + ^- (unit @t) + ?. ?=([%o *] item) ~ + =/ active (~(get by p.item) 'active') + =/ unit-val (~(get by p.item) 'unit') + =/ id-val (~(get by p.item) 'id') + ?. ?=([~ %b *] active) ~ + ?. =(%.y p.u.active) ~ + ?. ?=([~ %s *] unit-val) ~ + ?. =('sat' p.u.unit-val) ~ + ?~ id-val ~ + ?. ?=([%s *] u.id-val) ~ + (some p.u.id-val) + =/ kid=@t ?~(matching '' i.matching) + ?: =('' kid) + ~& >>> %mint-no-active-keyset + =. pending-mints (~(del by pending-mints) nonce) + `this + :: fetch keys for this keyset + =. pending-mints + (~(put by pending-mints) nonce u.pending(keyset-id kid, step %keyset)) + =/ mint-clean=tape (clean-mint-url:ca mint.u.pending) + =/ keys-url=@t (crip ;:(weld mint-clean "/v1/keys/" (trip kid))) + :_ this + :~ [%pass /iris/mint-keyset/[nonce] %arvo %i %request [%'GET' keys-url ~ ~] *outbound-config:iris] + == + :: + :: -- Mint flow: fetch keyset keys -- + :: + [%iris %mint-keyset @ ~] + =/ nonce=@t i.t.t.wire + =/ pending (~(get by pending-mints) nonce) + ?~ pending `this + ?. ?=([%iris %http-response *] sign-arvo) + =. pending-mints (~(del by pending-mints) nonce) + `this + =/ =client-response:iris client-response.sign-arvo + ?. ?=([%finished *] client-response) `this + =/ response=response-header:http response-header.client-response + =/ body=(unit octs) ?~(full-file.client-response ~ `data.u.full-file.client-response) + ?. =(200 status-code.response) + =. pending-mints (~(del by pending-mints) nonce) + `this + ?~ body + =. pending-mints (~(del by pending-mints) nonce) + `this + =/ resp-json (de:json:html q.u.body) + ?~ resp-json + =. pending-mints (~(del by pending-mints) nonce) + `this + =/ jon u.resp-json + ?. ?=([%o *] jon) + =. pending-mints (~(del by pending-mints) nonce) + `this + =/ keys-val=(unit json) + =/ ks (~(get by p.jon) 'keysets') + ?~ ks (~(get by p.jon) 'keys') + ?. ?=([%a *] u.ks) (~(get by p.jon) 'keys') + =/ first (snag 0 p.u.ks) + ?. ?=([%o *] first) (~(get by p.jon) 'keys') + (~(get by p.first) 'keys') + ?~ keys-val + =. pending-mints (~(del by pending-mints) nonce) + `this + ?. ?=([%o *] u.keys-val) + =. pending-mints (~(del by pending-mints) nonce) + `this + =/ key-map=(map @ud @t) + %- ~(rep by p.u.keys-val) + |= [[amt-key=@t hex-val=json] acc=(map @ud @t)] + ?. ?=([%s *] hex-val) acc + =/ amt=@ud (roll (trip amt-key) |=([c=@ a=@ud] (add (mul a 10) (sub c '0')))) + ?: =(0 amt) acc + (~(put by acc) amt p.hex-val) + =. mint-keysets (~(put by mint-keysets) keyset-id.u.pending key-map) + :: now request mint quote + =. pending-mints + (~(put by pending-mints) nonce u.pending(step %quote)) + =/ quote-body=@t (en:json:html (build-mint-quote-request:ca amount.u.pending 'sat')) + =/ quote-octs=octs [(met 3 quote-body) quote-body] + =/ mint-clean=tape (clean-mint-url:ca mint.u.pending) + =/ quote-url=@t (crip (weld mint-clean "/v1/mint/quote/bolt11")) + :_ this + :~ [%pass /iris/mint-quote/[nonce] %arvo %i %request [%'POST' quote-url ~[['content-type' 'application/json']] `quote-octs] *outbound-config:iris] + == + :: + :: -- Mint flow: receive quote with invoice -- + :: + [%iris %mint-quote @ ~] + =/ nonce=@t i.t.t.wire + =/ pending (~(get by pending-mints) nonce) + ?~ pending `this + ?. ?=([%iris %http-response *] sign-arvo) + =. pending-mints (~(del by pending-mints) nonce) + `this + =/ =client-response:iris client-response.sign-arvo + ?. ?=([%finished *] client-response) `this + =/ response=response-header:http response-header.client-response + =/ body=(unit octs) ?~(full-file.client-response ~ `data.u.full-file.client-response) + ?. =(200 status-code.response) + =. pending-mints (~(del by pending-mints) nonce) + `this + ?~ body + =. pending-mints (~(del by pending-mints) nonce) + `this + =/ resp-json (de:json:html q.u.body) + ?~ resp-json + =. pending-mints (~(del by pending-mints) nonce) + `this + =/ quote-result (parse-mint-quote:ca u.resp-json) + ?~ quote-result + =. pending-mints (~(del by pending-mints) nonce) + `this + =/ expiry-da=@da (add ~1970.1.1 (mul expiry.u.quote-result (bex 64))) + =. pending-mints + %+ ~(put by pending-mints) nonce + u.pending(quote-id quote.u.quote-result, bolt11 request.u.quote-result, expiry expiry-da, step %check-quote) + :: start polling timer + :_ this + :~ [%pass /timer/mint/[nonce] %arvo %b %wait (add now.bowl ~s5)] + == + :: + :: -- Mint flow: poll quote status -- + :: + [%iris %mint-check @ ~] + =/ nonce=@t i.t.t.wire + =/ pending (~(get by pending-mints) nonce) + ?~ pending `this + ?. ?=([%iris %http-response *] sign-arvo) + =. pending-mints (~(del by pending-mints) nonce) + `this + =/ =client-response:iris client-response.sign-arvo + ?. ?=([%finished *] client-response) `this + =/ response=response-header:http response-header.client-response + =/ body=(unit octs) ?~(full-file.client-response ~ `data.u.full-file.client-response) + ?. =(200 status-code.response) + :_ this + :~ [%pass /timer/mint/[nonce] %arvo %b %wait (add now.bowl ~s5)] + == + ?~ body + :_ this + :~ [%pass /timer/mint/[nonce] %arvo %b %wait (add now.bowl ~s5)] + == + =/ resp-json (de:json:html q.u.body) + ?~ resp-json + :_ this + :~ [%pass /timer/mint/[nonce] %arvo %b %wait (add now.bowl ~s5)] + == + =/ quote-result (parse-mint-quote:ca u.resp-json) + ?~ quote-result + :_ this + :~ [%pass /timer/mint/[nonce] %arvo %b %wait (add now.bowl ~s5)] + == + ?: =(state.u.quote-result 'PAID') + :: invoice paid — generate blinded outputs and mint tokens + =/ amounts=(list @ud) (split-amount:ca amount.u.pending) + =/ idx=@ud 0 + =/ secrets=(list @t) ~ + =/ bfactors=(list @) ~ + =/ mint-outputs=(list [amount=@ud id=@t b-hex=@t]) ~ + |- ^- (quip card _this) + ?: (gte idx (lent amounts)) + :: all outputs generated, send mint request + =/ mint-req=json (build-mint-request:ca quote-id.u.pending (flop mint-outputs)) + =/ mint-body=@t (en:json:html mint-req) + =/ mint-octs=octs [(met 3 mint-body) mint-body] + =/ mint-clean=tape (clean-mint-url:ca mint.u.pending) + =/ mint-url=@t (crip (weld mint-clean "/v1/mint/bolt11")) + =. pending-mints + (~(put by pending-mints) nonce u.pending(step %mint-tokens, secrets (flop secrets), blinding-factors (flop bfactors))) + :_ this + :~ [%pass /iris/mint-exec/[nonce] %arvo %i %request [%'POST' mint-url ~[['content-type' 'application/json']] `mint-octs] *outbound-config:iris] + == + =/ amt=@ud (snag idx amounts) + =/ eny-seed=@ (sham [eny.bowl nonce idx now.bowl]) + =/ [b-hex=@t secret=@t blinding-factor=@] (make-output:ca amt keyset-id.u.pending eny-seed) + %= $ + idx +(idx) + secrets [secret secrets] + bfactors [blinding-factor bfactors] + mint-outputs [[amt keyset-id.u.pending b-hex] mint-outputs] + == + :: not paid yet — schedule another check + :_ this + :~ [%pass /timer/mint/[nonce] %arvo %b %wait (add now.bowl ~s5)] + == + :: + :: -- Mint flow: receive minted tokens -- + :: + [%iris %mint-exec @ ~] + =/ nonce=@t i.t.t.wire + =/ pending (~(get by pending-mints) nonce) + ?~ pending `this + ?. ?=([%iris %http-response *] sign-arvo) + =. pending-mints (~(del by pending-mints) nonce) + `this + =/ =client-response:iris client-response.sign-arvo + ?. ?=([%finished *] client-response) `this + =/ response=response-header:http response-header.client-response + =/ body=(unit octs) ?~(full-file.client-response ~ `data.u.full-file.client-response) + ?. =(200 status-code.response) + ~& >>> [%mint-exec-rejected status-code.response] + =. pending-mints (~(del by pending-mints) nonce) + `this + ?~ body + =. pending-mints (~(del by pending-mints) nonce) + `this + =/ resp-json (de:json:html q.u.body) + ?~ resp-json + =. pending-mints (~(del by pending-mints) nonce) + `this + :: parse signatures and unblind + =/ sigs (parse-swap-response:ca u.resp-json) + =/ key-map=(map @ud @t) (~(gut by mint-keysets) keyset-id.u.pending *(map @ud @t)) + =/ mint-keys=(map @ud [x=@ y=@]) + %- ~(run by key-map) + |= hex=@t + =/ result (mule |.((hex-to-point:ca hex))) + ?:(?=([%& *] result) p.result [0 0]) + =/ new-proofs=(list cashu-proof) + %: finalize-proofs:ca + sigs + secrets.u.pending + blinding-factors.u.pending + mint-keys + == + :: add proofs to wallet + =/ existing-proofs=(list cashu-proof) (~(gut by wallet) mint.u.pending ~) + =. wallet (~(put by wallet) mint.u.pending (weld existing-proofs new-proofs)) + =. pending-mints (~(del by pending-mints) nonce) + ~& > [%mint-success (lent new-proofs) %proofs] + `this + :: + :: -- Mint flow: timer fires to poll -- + :: + [%timer %mint @ ~] + =/ nonce=@t i.t.t.wire + =/ pending (~(get by pending-mints) nonce) + ?~ pending `this + ?. ?=([%behn %wake *] sign-arvo) `this + :: check if expired + ?: &(!=(expiry.u.pending *@da) (gth now.bowl expiry.u.pending)) + ~& >>> %mint-quote-expired + =. pending-mints (~(del by pending-mints) nonce) + `this + :: poll quote status + =/ mint-clean=tape (clean-mint-url:ca mint.u.pending) + =/ check-url=@t (crip ;:(weld mint-clean "/v1/mint/quote/bolt11/" (trip quote-id.u.pending))) + :_ this + :~ [%pass /iris/mint-check/[nonce] %arvo %i %request [%'GET' check-url ~ ~] *outbound-config:iris] + == + :: + :: -- Verify flow: fetch keyset keys for swap -- + :: + [%iris %verify-keys @ ~] + =/ vid=@t i.t.t.wire + =/ pv (~(get by pending-verifies) vid) + ?~ pv `this + ?. ?=([%iris %http-response *] sign-arvo) + =. pending-verifies + (~(put by pending-verifies) vid u.pv(result %failed, error 'keyset fetch failed')) + `this + =/ =client-response:iris client-response.sign-arvo + ?. ?=([%finished *] client-response) `this + =/ response=response-header:http response-header.client-response + =/ body=(unit octs) ?~(full-file.client-response ~ `data.u.full-file.client-response) + ?. =(200 status-code.response) + =. pending-verifies + (~(put by pending-verifies) vid u.pv(result %failed, error 'mint keyset request rejected')) + `this + ?~ body + =. pending-verifies + (~(put by pending-verifies) vid u.pv(result %failed, error 'empty keyset response')) + `this + =/ resp-json (de:json:html q.u.body) + ?~ resp-json + =. pending-verifies + (~(put by pending-verifies) vid u.pv(result %failed, error 'invalid keyset json')) + `this + :: parse keys — try both /v1/keys/{id} and /v1/keysets formats + =/ jon u.resp-json + ?. ?=([%o *] jon) + =. pending-verifies + (~(put by pending-verifies) vid u.pv(result %failed, error 'keyset not an object')) + `this + =/ keys-val=(unit json) + =/ ks (~(get by p.jon) 'keysets') + ?~ ks (~(get by p.jon) 'keys') + ?. ?=([%a *] u.ks) (~(get by p.jon) 'keys') + =/ first (snag 0 p.u.ks) + ?. ?=([%o *] first) (~(get by p.jon) 'keys') + (~(get by p.first) 'keys') + ?~ keys-val + =. pending-verifies + (~(put by pending-verifies) vid u.pv(result %failed, error 'no keys in response')) + `this + ?. ?=([%o *] u.keys-val) + =. pending-verifies + (~(put by pending-verifies) vid u.pv(result %failed, error 'keys not an object')) + `this + =/ key-map=(map @ud @t) + %- ~(rep by p.u.keys-val) + |= [[amt-key=@t hex-val=json] acc=(map @ud @t)] + ?. ?=([%s *] hex-val) acc + =/ amt=@ud (roll (trip amt-key) |=([c=@ a=@ud] (add (mul a 10) (sub c '0')))) + ?: =(0 amt) acc + (~(put by acc) amt p.hex-val) + =. mint-keysets (~(put by mint-keysets) keyset-id.u.pv key-map) + :: build swap request: create fresh outputs for each input proof + =/ amounts=(list @ud) (turn tokens.u.pv |=(p=cashu-proof amount.p)) + =/ idx=@ud 0 + =/ secrets=(list @t) ~ + =/ bfactors=(list @) ~ + =/ outputs=(list [amount=@ud id=@t b-hex=@t]) ~ + |- ^- (quip card _this) + ?: (gte idx (lent amounts)) + :: build swap JSON + =/ inputs-json=json + :- %a + %+ turn tokens.u.pv + |= p=cashu-proof + %- pairs:enjs:format + :~ ['amount' (numb:enjs:format amount.p)] + ['id' s+id.p] + ['secret' s+secret.p] + ['C' s+c.p] + == + =/ swap-req=json + (build-swap-request:ca inputs-json (flop outputs)) + =/ swap-body=@t (en:json:html swap-req) + =/ swap-octs=octs [(met 3 swap-body) swap-body] + =/ mint-clean=tape (clean-mint-url:ca mint.u.pv) + =/ swap-url=@t (crip (weld mint-clean "/v1/swap")) + =. pending-verifies + %+ ~(put by pending-verifies) vid + u.pv(step %swap, secrets (flop secrets), blinding-factors (flop bfactors)) + :_ this + :~ [%pass /iris/verify-swap/[vid] %arvo %i %request [%'POST' swap-url ~[['content-type' 'application/json']] `swap-octs] *outbound-config:iris] + == + =/ amt=@ud (snag idx amounts) + =/ eny-seed=@ (sham [eny.bowl vid idx now.bowl]) + =/ [b-hex=@t secret=@t blinding-factor=@] (make-output:ca amt keyset-id.u.pv eny-seed) + %= $ + idx +(idx) + secrets [secret secrets] + bfactors [blinding-factor bfactors] + outputs [[amt keyset-id.u.pv b-hex] outputs] + == + :: + :: -- Verify flow: receive swap result -- + :: + [%iris %verify-swap @ ~] + =/ vid=@t i.t.t.wire + =/ pv (~(get by pending-verifies) vid) + ?~ pv `this + ?. ?=([%iris %http-response *] sign-arvo) + =. pending-verifies + (~(put by pending-verifies) vid u.pv(result %failed, error 'swap request failed')) + `this + =/ =client-response:iris client-response.sign-arvo + ?. ?=([%finished *] client-response) `this + =/ response=response-header:http response-header.client-response + =/ body=(unit octs) ?~(full-file.client-response ~ `data.u.full-file.client-response) + ?. =(200 status-code.response) + =/ err-body=@t + ?~ body 'no body' + (crip (scag 200 (trip q.u.body))) + =. pending-verifies + (~(put by pending-verifies) vid u.pv(result %failed, error (crip ;:(weld "swap rejected: " (trip err-body))))) + `this + ?~ body + =. pending-verifies + (~(put by pending-verifies) vid u.pv(result %failed, error 'empty swap response')) + `this + =/ resp-json (de:json:html q.u.body) + ?~ resp-json + =. pending-verifies + (~(put by pending-verifies) vid u.pv(result %failed, error 'invalid swap json')) + `this + :: parse signatures and unblind + =/ sigs (parse-swap-response:ca u.resp-json) + =/ key-map=(map @ud @t) (~(gut by mint-keysets) keyset-id.u.pv *(map @ud @t)) + =/ mint-keys=(map @ud [x=@ y=@]) + %- ~(run by key-map) + |= hex=@t + =/ result (mule |.((hex-to-point:ca hex))) + ?:(?=([%& *] result) p.result [0 0]) + =/ new-proofs=(list cashu-proof) + %: finalize-proofs:ca + sigs + secrets.u.pv + blinding-factors.u.pv + mint-keys + == + :: swap succeeded — tokens are real, store them in wallet + =/ existing=(list cashu-proof) (~(gut by wallet) mint.u.pv ~) + =. wallet (~(put by wallet) mint.u.pv (weld existing new-proofs)) + =. pending-verifies + (~(put by pending-verifies) vid u.pv(result %verified)) + ~& > [%verify-swap-success (lent new-proofs) %proofs token-total.u.pv %sats] + `this == ++ on-fail on-fail:def -- diff --git a/desk/lib/cashu.hoon b/desk/lib/cashu.hoon new file mode 100644 index 0000000..73389e5 --- /dev/null +++ b/desk/lib/cashu.hoon @@ -0,0 +1,423 @@ +:: lib/cashu.hoon: Cashu wallet operations (NUT-00/NUT-03/NUT-05) +:: +:: Standard-compliant BDHKE using zuse's jetted secp256k1 operations. +:: Implements hash-to-curve, blinding, unblinding per NUT-00 spec. +:: +|% +:: secp256k1 field prime +++ secp-p + 0xffff.ffff.ffff.ffff.ffff.ffff.ffff.ffff. + ffff.ffff.ffff.ffff.ffff.fffe.ffff.fc2f +:: secp256k1 curve order +++ secp-n + 0xffff.ffff.ffff.ffff.ffff.ffff.ffff.fffe. + baae.dce6.af48.a03b.bfd2.5e8c.d036.4141 +:: secp256k1 generator point +++ secp-g + ^- [x=@ y=@] + :* x=0x79be.667e.f9dc.bbac.55a0.6295.ce87.0b07. + 029b.fcdb.2dce.28d9.59f2.815b.16f8.1798 + y=0x483a.da77.26a3.c465.5da4.fbfc.0e11.08a8. + fd17.b448.a685.5419.9c47.d08f.fb10.d4b8 + == +:: +:: -- Hex helpers -- +:: +++ hex-to-bytes + |= hex=@t + ^- @ + %+ roll (trip hex) + |= [c=@ acc=@] + =/ nib + ?: &((gte c '0') (lte c '9')) (sub c '0') + ?: &((gte c 'a') (lte c 'f')) (add 10 (sub c 'a')) + ?: &((gte c 'A') (lte c 'F')) (add 10 (sub c 'A')) + ~|(%bad-hex-char !!) + (add (mul acc 16) nib) +:: +++ bytes-to-hex + |= [val=@ width=@ud] + ^- @t + =/ hex-chars=tape "0123456789abcdef" + =/ out=tape + =/ idx=@ud 0 + =/ acc=tape ~ + |- ^- tape + ?: =(idx width) acc + =/ byte (cut 3 [(sub (dec width) idx) 1] val) + =/ hi (snag (div byte 16) hex-chars) + =/ lo (snag (mod byte 16) hex-chars) + $(idx +(idx), acc (snoc (snoc acc hi) lo)) + (crip out) +:: +:: -- Point decompression (pure Hoon, replaces zuse) -- +:: +:: Decompress a 33-byte compressed secp256k1 point to affine [x y] +++ ec-decompress + |= compressed=@ + ^- [x=@ y=@] + =/ prefix=@ (cut 3 [32 1] compressed) + ?> |(=(2 prefix) =(3 prefix)) + =/ x=@ (end [3 32] compressed) + =/ fop ~(. fo secp-p) + =/ x3 (pro:fop x (pro:fop x x)) + =/ y2 (sum:fop x3 7) + :: y = y2^((p+1)/4) mod p (works since p ≡ 3 mod 4) + =/ y=@ (~(exp fo secp-p) (div (add secp-p 1) 4) y2) + :: verify y^2 == y2 + ?> =((pro:fop y y) y2) + :: adjust parity to match prefix + =/ need-odd=? =(3 prefix) + =? y !=(=(1 (mod y 2)) need-odd) + (sub secp-p y) + [x y] +:: +:: -- Point serialization -- +:: +:: Parse compressed hex point ("02abc..." / "03abc...", 66 chars) to point +++ hex-to-point + |= hex=@t + ^- [x=@ y=@] + =/ chars=tape (trip hex) + ?> =(66 (lent chars)) + =/ prefix=@t (crip (scag 2 chars)) + ?> |(=(prefix '02') =(prefix '03')) + =/ compressed=@ (hex-to-bytes hex) + (ec-decompress compressed) +:: +:: Compress point to hex string +++ point-to-hex + |= pt=[x=@ y=@] + ^- @t + :: manual compression: 02 if y even, 03 if y odd, then x big-endian + =/ prefix=@ ?:(=(0 (mod y.pt 2)) 2 3) + =/ compressed=@ (add (lsh [3 32] prefix) x.pt) + (bytes-to-hex compressed 33) +:: +:: -- Hash-to-curve (NUT-00 spec) -- +:: +:: Domain separator per Cashu NUT-00 +++ domain-separator 'Secp256k1_HashToCurve_Cashu_' +:: +++ hash-to-curve + |= message=@ + ^- [x=@ y=@] + =/ domain=@ domain-separator + =/ domain-len=@ud (met 3 domain) + =/ msg-len=@ud (met 3 message) + =/ msg-hash=@ (shay (add domain-len msg-len) (cat 3 domain message)) + =/ counter=@ud 0 + |- + ?> (lth counter 65.536) + :: SHA-256(msg_hash || counter_le32) + :: counter as 4-byte LE is just the atom value; shay reads 36 bytes + =/ hash=@ (rev 3 32 (shay 36 (cat 3 msg-hash counter))) + :: try to decompress as 02 || hash (even-y point) + =/ compressed=@ (add (lsh [3 32] 2) hash) + =/ result (mule |.((ec-decompress compressed))) + ?: ?=([%& *] result) + p.result + $(counter +(counter)) +:: +:: -- Elliptic curve point addition (affine, secp256k1) -- +:: +:: Uses Hoon stdlib fo core for modular field arithmetic. +:: Replaces zuse add-points which produces invalid results. +:: +++ ec-add + |= [p1=[x=@ y=@] p2=[x=@ y=@]] + ^- [x=@ y=@] + =/ fop ~(. fo secp-p) + ?: &(=(x.p1 x.p2) =(y.p1 y.p2)) + :: point doubling: lam = 3*x1^2 / (2*y1) + =/ lam (fra:fop (pro:fop 3 (pro:fop x.p1 x.p1)) (pro:fop 2 y.p1)) + =/ x3 (dif:fop (dif:fop (pro:fop lam lam) x.p1) x.p2) + =/ y3 (dif:fop (pro:fop lam (dif:fop x.p1 x3)) y.p1) + [x3 y3] + :: point addition: lam = (y2 - y1) / (x2 - x1) + =/ lam (fra:fop (dif:fop y.p2 y.p1) (dif:fop x.p2 x.p1)) + =/ x3 (dif:fop (dif:fop (pro:fop lam lam) x.p1) x.p2) + =/ y3 (dif:fop (pro:fop lam (dif:fop x.p1 x3)) y.p1) + [x3 y3] +:: +:: Scalar multiplication via double-and-add +:: +++ ec-mul + |= [pt=[x=@ y=@] k=@] + ^- [x=@ y=@] + =/ res=[x=@ y=@] pt + =/ acc=[x=@ y=@] pt + =/ first=? & + =/ bits=@ud (met 0 k) + =/ idx=@ud 0 + |- + ?: =(idx bits) res + ?: =(1 (cut 0 [idx 1] k)) + ?: first + $(idx +(idx), res acc, acc (ec-add acc acc), first |) + $(idx +(idx), res (ec-add res acc), acc (ec-add acc acc)) + $(idx +(idx), acc (ec-add acc acc)) +:: +:: -- BDHKE operations -- +:: +:: Blind a message for signing: B_ = Y + r*G +:: Returns [B_ r] where r is the blinding factor +++ blind-message + |= [secret=@t r=@] + ^- [b-prime=[x=@ y=@] blinding-factor=@] + :: reduce r modulo curve order to ensure valid scalar + =/ r-mod=@ (mod r secp-n) + =? r-mod =(0 r-mod) 1 + =/ yy (hash-to-curve secret) + =/ r-g (ec-mul secp-g r-mod) + =/ b-prime (ec-add yy r-g) + [b-prime=b-prime blinding-factor=r-mod] +:: +:: Unblind a signature: C = C_ - r*K +:: C_ = blinded signature from mint +:: r = blinding factor used during blinding +:: K = mint's public key for this denomination +++ unblind-signature + |= [c-blind=[x=@ y=@] r=@ mint-key=[x=@ y=@]] + ^- [x=@ y=@] + =/ r-k (ec-mul mint-key r) + :: negate r*K: flip y coordinate (mod p) + =/ neg-r-k r-k(y (sub secp-p y.r-k)) + =/ result (ec-add c-blind neg-r-k) + result +:: +:: -- NUT-03 swap request/response builders -- +:: +:: Build a single blinded output for swap +:: Returns [B_hex secret blinding-factor] +++ make-output + |= [amount=@ud keyset-id=@t eny=@] + ^- [b-hex=@t secret=@t blinding-factor=@] + :: generate random secret (32 bytes hex) + =/ secret=@t (bytes-to-hex (shax eny) 32) + :: use hash of eny as blinding factor (ensure non-zero, < curve order) + =/ r=@ (shax (cat 3 eny 'blind')) + =/ [b-prime=[x=@ y=@] blinding-factor=@] (blind-message secret r) + :: verify the point is valid by round-trip: compress then decompress + =/ b-hex=@t (point-to-hex b-prime) + =/ check (mule |.((hex-to-point b-hex))) + ?. ?=([%& *] check) + :: point invalid, retry with different entropy + $(eny (shax (cat 3 eny 'retry'))) + [b-hex secret blinding-factor] +:: +:: Build swap request JSON from user proofs and generated outputs +++ build-swap-request + |= [inputs=json outputs=(list [amount=@ud id=@t b-hex=@t])] + ^- json + %- pairs:enjs:format + :~ ['inputs' inputs] + :- 'outputs' + :- %a + %+ turn outputs + |= [amount=@ud id=@t b-hex=@t] + %- pairs:enjs:format + :~ ['amount' (numb:enjs:format amount)] + ['id' s+id] + ['B_' s+b-hex] + == + == +:: +:: Parse swap response: extract blinded signatures +++ parse-swap-response + |= jon=json + ^- (list [amount=@ud id=@t c-hex=@t]) + ?. ?=([%o *] jon) ~ + =/ sigs (~(get by p.jon) 'signatures') + ?~ sigs ~ + ?. ?=([%a *] u.sigs) ~ + %+ turn p.u.sigs + |= sig=json + ^- [amount=@ud id=@t c-hex=@t] + ?. ?=([%o *] sig) [0 '' ''] + =/ amt=json (~(gut by p.sig) 'amount' [%n '0']) + =/ kid=json (~(gut by p.sig) 'id' [%s '']) + =/ c-val=json (~(gut by p.sig) 'C_' [%s '']) + =/ amount=@ud + ?. ?=([%n *] amt) 0 + (roll (trip p.amt) |=([c=@ a=@ud] (add (mul a 10) (sub c '0')))) + =/ keyset-id=@t + ?. ?=([%s *] kid) '' + p.kid + =/ c-hex=@t + ?. ?=([%s *] c-val) '' + p.c-val + [amount keyset-id c-hex] +:: +:: Unblind all signatures and produce final proofs +++ finalize-proofs + |= $: sigs=(list [amount=@ud id=@t c-hex=@t]) + secrets=(list @t) + blinding-factors=(list @) + mint-keys=(map @ud [x=@ y=@]) + == + ^- (list [amount=@ud id=@t secret=@t c=@t]) + =/ idx=@ud 0 + =/ acc=(list [amount=@ud id=@t secret=@t c=@t]) ~ + |- + ?: |((gte idx (lent sigs)) (gte idx (lent secrets))) + (flop acc) + =/ [amt=@ud kid=@t c-hex=@t] (snag idx sigs) + =/ secret=@t (snag idx secrets) + =/ r=@ (snag idx blinding-factors) + =/ mint-key (~(get by mint-keys) amt) + ?~ mint-key + $(idx +(idx)) + =/ c-blind (hex-to-point c-hex) + =/ c-unblind (unblind-signature c-blind r u.mint-key) + =/ c-final=@t (point-to-hex c-unblind) + $(idx +(idx), acc [[amt kid secret c-final] acc]) +:: +:: -- NUT-05 melt (Lightning withdrawal) -- +:: +:: Build melt quote request +++ build-melt-quote-request + |= [invoice=@t unit=@t] + ^- json + %- pairs:enjs:format + :~ ['request' s+invoice] + ['unit' s+unit] + == +:: +:: Parse melt quote response +++ parse-melt-quote + |= jon=json + ^- (unit [quote=@t amount=@ud fee-reserve=@ud]) + ?. ?=([%o *] jon) ~ + =/ q (~(get by p.jon) 'quote') + =/ a (~(get by p.jon) 'amount') + =/ f (~(get by p.jon) 'fee_reserve') + ?~ q ~ + ?~ a ~ + ?~ f ~ + :- ~ + :+ ?:(?=([%s *] u.q) p.u.q '') + ?:(?=([%n *] u.a) (roll (trip p.u.a) |=([c=@ a=@ud] (add (mul a 10) (sub c '0')))) 0) + ?:(?=([%n *] u.f) (roll (trip p.u.f) |=([c=@ a=@ud] (add (mul a 10) (sub c '0')))) 0) +:: +:: Build melt execution request from stored proofs +++ build-melt-request + |= [quote-id=@t proofs=(list [amount=@ud id=@t secret=@t c=@t])] + ^- json + %- pairs:enjs:format + :~ ['quote' s+quote-id] + :- 'inputs' + :- %a + %+ turn proofs + |= [amount=@ud id=@t secret=@t c=@t] + %- pairs:enjs:format + :~ ['amount' (numb:enjs:format amount)] + ['id' s+id] + ['secret' s+secret] + ['C' s+c] + == + == +:: +:: Parse melt response +++ parse-melt-response + |= jon=json + ^- (unit [state=@t paid=?]) + ?. ?=([%o *] jon) ~ + =/ st (~(get by p.jon) 'state') + ?~ st ~ + =/ state=@t ?:(?=([%s *] u.st) p.u.st '') + `[state =(state 'PAID')] +:: +:: -- NUT-04 mint (Lightning invoice) -- +:: +:: Build mint quote request +++ build-mint-quote-request + |= [amount=@ud unit=@t] + ^- json + %- pairs:enjs:format + :~ ['amount' (numb:enjs:format amount)] + ['unit' s+unit] + == +:: +:: Parse mint quote response +++ parse-mint-quote + |= jon=json + ^- (unit [quote=@t request=@t state=@t expiry=@ud]) + ?. ?=([%o *] jon) ~ + =/ q (~(get by p.jon) 'quote') + =/ r (~(get by p.jon) 'request') + =/ s (~(get by p.jon) 'state') + =/ e (~(get by p.jon) 'expiry') + ?~ q ~ + ?~ r ~ + ?~ s ~ + ?~ e ~ + :- ~ + :^ ?:(?=([%s *] u.q) p.u.q '') + ?:(?=([%s *] u.r) p.u.r '') + ?:(?=([%s *] u.s) p.u.s '') + ?:(?=([%n *] u.e) (roll (trip p.u.e) |=([c=@ a=@ud] (add (mul a 10) (sub c '0')))) 0) +:: +:: Build mint token request (NUT-04 step 2) +++ build-mint-request + |= [quote-id=@t outputs=(list [amount=@ud id=@t b-hex=@t])] + ^- json + %- pairs:enjs:format + :~ ['quote' s+quote-id] + :- 'outputs' + :- %a + %+ turn outputs + |= [amount=@ud id=@t b-hex=@t] + %- pairs:enjs:format + :~ ['amount' (numb:enjs:format amount)] + ['id' s+id] + ['B_' s+b-hex] + == + == +:: +:: -- Keyset parsing -- +:: +:: Parse mint keyset response: {keys: {amount_str: hex_pubkey, ...}} +++ parse-keyset + |= jon=json + ^- (map @ud [x=@ y=@]) + ?. ?=([%o *] jon) *(map @ud [x=@ y=@]) + =/ keys-val (~(get by p.jon) 'keys') + ?~ keys-val *(map @ud [x=@ y=@]) + ?. ?=([%o *] u.keys-val) *(map @ud [x=@ y=@]) + %- ~(rep by p.u.keys-val) + |= [[amt-key=@t hex-val=json] acc=(map @ud [x=@ y=@])] + ?. ?=([%s *] hex-val) acc + =/ amt=@ud (roll (trip amt-key) |=([c=@ a=@ud] (add (mul a 10) (sub c '0')))) + ?: =(0 amt) acc + =/ pt-result (mule |.((hex-to-point p.hex-val))) + ?. ?=([%& *] pt-result) acc + (~(put by acc) amt p.pt-result) +:: +:: -- Amount splitting -- +:: +:: Split amount into powers of 2 (standard Cashu denomination) +++ split-amount + |= total=@ud + ^- (list @ud) + ?: =(0 total) ~ + =/ acc=(list @ud) ~ + =/ bit=@ud 0 + |- + ?: (gte (bex bit) (mul 2 total)) + acc + ?: =((mod (div total (bex bit)) 2) 1) + $(bit +(bit), acc [(bex bit) acc]) + $(bit +(bit)) +:: +:: -- URL helpers -- +:: +++ clean-mint-url + |= mint=@t + ^- tape + =/ clean=tape (trip mint) + =/ rev (flop clean) + =? clean ?=([%'/' *] rev) + (snip clean) + clean +-- diff --git a/desk/lib/vitriol-ui.hoon b/desk/lib/vitriol-ui.hoon new file mode 100644 index 0000000..dcb8526 --- /dev/null +++ b/desk/lib/vitriol-ui.hoon @@ -0,0 +1,281 @@ +:: /lib/vitriol-ui/hoon +:: Sail UI for vitriol — landing page and admin panel +:: +:: Arms: +:: html-response — wrap a manx page in an HTTP 200 response +:: redirect-response — HTTP 303 redirect +:: render-home — landing page with app description and endpoint docs +:: render-admin — admin panel split into committer and maintainer sections +:: +/- *vitriol +/+ server +|% +++ html-response + |= [eyre-id=@ta page=manx] + ^- (list card:agent:gall) + =/ bod=octs + (as-octs:mimes:html (crip (en-xml:html page))) + %+ give-simple-payload:app:server eyre-id + :- :- 200 + :~ ['content-type' 'text/html; charset=utf-8'] + == + [~ bod] +:: +++ redirect-response + |= [eyre-id=@ta url=@t] + ^- (list card:agent:gall) + %+ give-simple-payload:app:server eyre-id + :- [303 ~[['location' url]]] + ~ +:: +++ css + ^- tape + %- trip + ''' + * { margin: 0; padding: 0; box-sizing: border-box; } + body { font-family: monospace; background: #111; color: #e0e0e0; + max-width: 640px; margin: 0 auto; padding: 24px; } + h1 { color: #0f0; margin-bottom: 4px; font-size: 1.4em; } + h1 a { color: #0f0; text-decoration: none; } + h1 a:hover { text-decoration: underline; } + .sub { color: #666; margin-bottom: 24px; } + .sub a { color: #666; } + .sub a:hover { color: #0f0; } + section { background: #1a1a1a; border: 1px solid #333; + border-radius: 4px; padding: 16px; margin-bottom: 16px; } + h2 { color: #0a0; font-size: 1em; margin-bottom: 12px; + border-bottom: 1px solid #333; padding-bottom: 8px; } + h3 { color: #888; font-size: 0.85em; text-transform: uppercase; + letter-spacing: 1px; margin-bottom: 12px; } + p { line-height: 1.5; margin-bottom: 12px; } + code { background: #222; padding: 2px 6px; border-radius: 3px; color: #0f0; } + pre { background: #222; padding: 12px; border-radius: 4px; + overflow-x: auto; margin-bottom: 12px; color: #0f0; } + label { display: block; color: #888; font-size: 0.85em; margin-bottom: 2px; } + .val { color: #0f0; word-break: break-all; margin-bottom: 12px; } + .ship-row { display: flex; align-items: center; justify-content: space-between; + padding: 6px 8px; background: #222; border-radius: 3px; margin-bottom: 4px; } + .ship-row span { color: #e0e0e0; } + input[type=text], input[type=number] { + background: #222; border: 1px solid #444; color: #e0e0e0; + padding: 6px 8px; border-radius: 3px; font-family: monospace; + width: 100%; margin-bottom: 8px; } + input[type=text]:focus, input[type=number]:focus { outline: none; border-color: #0a0; } + button, input[type=submit] { + background: #222; border: 1px solid #444; color: #0f0; + padding: 6px 12px; border-radius: 3px; cursor: pointer; + font-family: monospace; font-size: 0.9em; } + button:hover, input[type=submit]:hover { background: #333; border-color: #0f0; } + .btn-danger { color: #f44; border-color: #a33; } + .btn-danger:hover { border-color: #f44; } + .toggle-form { display: flex; align-items: center; gap: 12px; } + .status { display: inline-block; padding: 2px 8px; border-radius: 3px; + font-size: 0.85em; } + .status-on { background: #0a2e0a; color: #0f0; border: 1px solid #0a0; } + .status-off { background: #2e0a0a; color: #f44; border: 1px solid #a33; } + .empty { color: #666; font-style: italic; padding: 8px 0; } + .balance { font-size: 1.2em; color: #ff0; margin-bottom: 12px; } + .pending { color: #fa0; font-size: 0.85em; } + .inline-form { display: flex; gap: 8px; align-items: flex-end; } + .inline-form input { margin-bottom: 0; } + .nav { margin-bottom: 16px; } + .nav a { color: #0a0; margin-right: 16px; text-decoration: none; } + .nav a:hover { color: #0f0; text-decoration: underline; } + .divider { border: 0; border-top: 1px solid #333; margin: 24px 0 16px; } + ''' +:: +:: -- Landing page -- +:: +++ render-home + |= our=@p + ^- manx + ;html + ;head + ;title: vitriol + ;style + ;+ ;/ css + == + == + ;body + ;h1: vitriol + ;div.sub: groundwire for github · {(trip (scot %p our))} + ;div.nav + ;a(href "/vitriol/admin"): admin panel + == + ;section + ;h2: what is vitriol? + ;p: vitriol is an Urbit agent that bridges on-chain identity to git workflows. it lets you sign commits with your ship's Ed25519 networking key — the same key attested on-chain via a Groundwire inscription — and verify those signatures in CI. + ;p: optionally, maintainers can require ecash token payments with each signed commit, and committers can load their wallet with sats via Lightning to include with their signatures. + == + ;section + ;h2: how it works + ;h3: for committers (signers) + ;p: install the pre-commit hook in your repo. when you commit, the hook calls your ship's vitriol agent to sign the commit content with your networking key. the signature and your @p are embedded in the commit message. + ;pre: ./hooks/install.sh + ;p: if the maintainer requires ecash payment, configure your ship's mint and load sats in the admin panel. the hook will encrypt tokens for the maintainer's pubkey and include them in the signature block. + ;h3: for maintainers (verifiers) + ;p: add vitriol's verify-commit endpoint to your CI pipeline. it checks the commit signature against the signer's on-chain Groundwire key via Jael. + ;pre: POST /vitriol/verify-commit + ;p: you can also require ecash payment, manage a ban list of @p's, and expose your encryption pubkey for receiving tokens — all from the admin panel. + == + ;section + ;h2: endpoints + ;h3: signing + ;p + ;code: POST /vitriol/sign + ;span: — sign commit content with your networking key + == + ;h3: verification + ;p + ;code: POST /vitriol/verify-commit + ;span: — verify a signature against on-chain key + == + ;h3: identity + ;p + ;code: GET /vitriol/pubkey + ;span: — your on-chain networking public key + == + ;p + ;code: GET /vitriol/ecash-pubkey + ;span: — your ecash encryption public key + == + ;p + ;code: GET /vitriol/check-id/~ship + ;span: — check if a ship has a Groundwire ID + == + ;h3: ban list + ;p + ;code: GET /vitriol/banned + ;span: — list banned ships + == + ;p + ;code: POST /vitriol/ban + ;span: — ban a ship (JSON body) + == + ;p + ;code: POST /vitriol/unban + ;span: — unban a ship (JSON body) + == + ;h3: ecash + ;p + ;code: GET /vitriol/sats-per-pr + ;span: — maintainer's price per PR (in sats) + == + == + == + == +:: +:: -- Admin panel -- +:: +++ render-admin + |= $: our=@p + ecash-key=(unit [sec=@ pub=@]) + banned=(set @p) + require-payment=? + sats-per-pr=(unit @ud) + mint=(unit @t) + wallet=(map @t (list cashu-proof)) + pending-mints=(map @t pending-mint-quote) + to-hex=$-([@ud @] @t) + == + ^- manx + =/ ships=(list @p) ~(tap in banned) + =/ balance=@ud + %- ~(rep by wallet) + |= [[m=@t proofs=(list cashu-proof)] acc=@ud] + (add acc (roll proofs |=([p=cashu-proof a=@ud] (add a amount.p)))) + =/ num-pending=@ud ~(wyt by pending-mints) + ;html + ;head + ;title: vitriol admin + ;style + ;+ ;/ css + == + == + ;body + ;h1 + ;a(href "/vitriol"): vitriol + == + ;div.sub + ;span: admin panel · {(trip (scot %p our))} · + ;a(href "/vitriol"): home + == + ;h3: committer + ;section + ;h2: wallet + ;div.balance: {(trip (scot %ud balance))} sats + ;+ ?: (gth num-pending 0) + ;div.pending: {(trip (scot %ud num-pending))} pending invoice(s) — waiting for payment... + ;div; + ;label: mint url + ;form(method "POST", action "/vitriol/admin/set-mint") + ;div.inline-form + ;input(type "text", name "mint", placeholder "https://mint.example.com", value ?~(mint "" (trip u.mint))); + ;input(type "submit", value "Set mint"); + == + == + ;+ ?~ mint + ;div.empty: set a mint url to load sats + ;div + ;label: load sats via lightning + ;form(method "POST", action "/vitriol/admin/load-sats") + ;div.inline-form + ;input(type "number", name "amount", placeholder "100", min "1"); + ;input(type "submit", value "Get invoice"); + == + == + == + == + ;hr.divider; + ;h3: maintainer + ;section + ;h2: ecash payment + ;div.toggle-form + ;span + ;+ ?: require-payment + ;span.status.status-on: required + ;span.status.status-off: not required + == + ;form(method "POST", action "/vitriol/admin/toggle-payment") + ;input(type "submit", value ?:(require-payment "Disable" "Enable")); + == + == + ;label: sats per PR + ;form(method "POST", action "/vitriol/admin/set-price") + ;div.inline-form + ;input(type "number", name "price", placeholder "0", min "0", value ?~(sats-per-pr "" (trip (scot %ud u.sats-per-pr)))); + ;input(type "submit", value "Set price"); + == + == + ;+ ?~ ecash-key + ;div.val: no keypair generated + ;div + ;label: encryption public key (for receiving ecash) + ;div.val: {(trip (to-hex 64 pub.u.ecash-key))} + == + == + ;section + ;h2: ban list ({(trip (scot %ud (lent ships)))}) + ;form(method "POST", action "/vitriol/admin/ban") + ;input(type "text", name "ship", placeholder "~sampel-palnet"); + ;input(type "submit", value "Ban ship"); + == + ;br; + ;+ ?: =(~ ships) + ;div.empty: no banned ships + ;div + ;* %+ turn ships + |= s=@p + ^- manx + ;div.ship-row + ;span: {(trip (scot %p s))} + ;form(method "POST", action "/vitriol/admin/unban", style "margin:0") + ;input(type "hidden", name "ship", value "{(trip (scot %p s))}"); + ;button.btn-danger(type "submit"): remove + == + == + == + == + == + == +-- diff --git a/desk/sur/vitriol.hoon b/desk/sur/vitriol.hoon index cc7c786..a8b30ef 100644 --- a/desk/sur/vitriol.hoon +++ b/desk/sur/vitriol.hoon @@ -1,5 +1,41 @@ :: /sur/vitriol/hoon -:: Groundwire for GitHub — types +:: Groundwire for GitHub — shared type definitions +:: +:: cashu-proof — a single Cashu token (NUT-00) +:: pending-mint-quote — in-flight Lightning invoice → token mint flow (NUT-04) +:: pending-verify — in-flight NUT-03 swap verification of incoming tokens :: |% ++$ cashu-proof + $: amount=@ud + id=@t + secret=@t + c=@t + == +:: ++$ pending-mint-quote + $: mint=@t + quote-id=@t + bolt11=@t + amount=@ud + keyset-id=@t + expiry=@da + step=?(%fetch-keys %keyset %quote %check-quote %mint-tokens) + secrets=(list @t) + blinding-factors=(list @) + == +:: ++$ pending-verify + $: signer=@p + life=@ud + mint=@t + tokens=(list cashu-proof) + token-total=@ud + step=?(%fetch-keys %swap) + keyset-id=@t + secrets=(list @t) + blinding-factors=(list @) + result=?(%pending %verified %failed) + error=@t + == -- diff --git a/desk/sys.kelvin b/desk/sys.kelvin index 5a6f9d5..88994c6 100644 --- a/desk/sys.kelvin +++ b/desk/sys.kelvin @@ -1 +1,2 @@ [%zuse 408] +[%zuse 409] diff --git a/hooks/groundwire-sign b/hooks/groundwire-sign index 379a92c..ad1b591 100755 --- a/hooks/groundwire-sign +++ b/hooks/groundwire-sign @@ -9,17 +9,20 @@ # # This script: # 1. Reads the commit content from stdin -# 2. Sends it to the committer's Groundwire ship for signing -# 3. Returns a structured signature block on stdout -# 4. Emits GPG-compatible status on fd 2 +# 2. Optionally fetches the maintainer's ecash pubkey and sats-per-pr price +# 3. Sends content (+ sats_required if applicable) to the committer's ship +# 4. Returns a structured signature block on stdout +# 5. Emits GPG-compatible status on fd 2 # # The signature block includes the signer's @p so the CI verifier can # look up the identity on-chain via its own ship, rather than trusting # the committer's self-reported pubkey. # # Configuration (git config or env vars): -# GROUNDWIRE_SIGN_ENDPOINT — base URL of the committer's ship (e.g. http://localhost:8080/vitriol) -# GROUNDWIRE_SIGN_TOKEN — auth cookie for the ship +# GROUNDWIRE_SIGN_ENDPOINT — base URL of the committer's ship (e.g. http://localhost:8080/vitriol) +# GROUNDWIRE_SIGN_TOKEN — auth cookie for the ship +# GROUNDWIRE_MAINTAINER_ENDPOINT — (optional) base URL of the maintainer's ship for ecash pubkey +# GROUNDWIRE_MAINTAINER_TOKEN — (optional) auth cookie for the maintainer's ship # # Install: # git config --global gpg.program /path/to/groundwire-sign @@ -32,6 +35,8 @@ set -euo pipefail ENDPOINT="${GROUNDWIRE_SIGN_ENDPOINT:-$(git config --get groundwire.sign-endpoint 2>/dev/null || echo "")}" TOKEN="${GROUNDWIRE_SIGN_TOKEN:-$(git config --get groundwire.sign-token 2>/dev/null || echo "")}" +MAINTAINER_ENDPOINT="${GROUNDWIRE_MAINTAINER_ENDPOINT:-$(git config --get groundwire.maintainer-endpoint 2>/dev/null || echo "")}" +MAINTAINER_TOKEN="${GROUNDWIRE_MAINTAINER_TOKEN:-$(git config --get groundwire.maintainer-token 2>/dev/null || echo "")}" if [ -z "$ENDPOINT" ]; then echo "groundwire-sign: GROUNDWIRE_SIGN_ENDPOINT not set" >&2 @@ -68,12 +73,55 @@ done COMMIT_CONTENT=$(cat) +# --- Fetch maintainer's ecash pubkey and price (optional) -------------------- + +ECASH_PUBKEY="" +SATS_REQUIRED="" + +if [ -n "$MAINTAINER_ENDPOINT" ]; then + # Fetch ecash pubkey + ECASH_RESPONSE=$(curl -s -f \ + -H "Cookie: ${MAINTAINER_TOKEN}" \ + "${MAINTAINER_ENDPOINT}/ecash-pubkey") || { + echo "groundwire-sign: warning: could not fetch maintainer ecash pubkey" >&2 + } + if [ -n "$ECASH_RESPONSE" ]; then + ECASH_CONFIGURED=$(echo "$ECASH_RESPONSE" | jq -r '.configured // empty') + if [ "$ECASH_CONFIGURED" = "true" ]; then + ECASH_PUBKEY=$(echo "$ECASH_RESPONSE" | jq -r '.pubkey // empty') + else + echo "groundwire-sign: warning: maintainer ecash not configured" >&2 + fi + fi + + # Fetch sats-per-pr price + PRICE_RESPONSE=$(curl -s -f \ + -H "Cookie: ${MAINTAINER_TOKEN}" \ + "${MAINTAINER_ENDPOINT}/sats-per-pr") || { + echo "groundwire-sign: warning: could not fetch maintainer price" >&2 + } + if [ -n "$PRICE_RESPONSE" ]; then + PRICE_CONFIGURED=$(echo "$PRICE_RESPONSE" | jq -r '.configured // empty') + if [ "$PRICE_CONFIGURED" = "true" ]; then + SATS_REQUIRED=$(echo "$PRICE_RESPONSE" | jq -r '.sats // empty') + fi + fi +fi + # --- Send to signing service -------------------------------------------------- -PAYLOAD=$(jq -n \ - --arg content "$COMMIT_CONTENT" \ - --arg key_id "$KEY_ID" \ - '{content: $content, key_id: $key_id}') +if [ -n "$SATS_REQUIRED" ] && [ "$SATS_REQUIRED" != "0" ]; then + PAYLOAD=$(jq -n \ + --arg content "$COMMIT_CONTENT" \ + --arg key_id "$KEY_ID" \ + --argjson sats "$SATS_REQUIRED" \ + '{content: $content, key_id: $key_id, sats_required: $sats}') +else + PAYLOAD=$(jq -n \ + --arg content "$COMMIT_CONTENT" \ + --arg key_id "$KEY_ID" \ + '{content: $content, key_id: $key_id}') +fi RESPONSE=$(curl -s -f \ -X POST \ @@ -85,9 +133,18 @@ RESPONSE=$(curl -s -f \ exit 1 } +# Check for error in response (e.g. insufficient balance) +ERROR=$(echo "$RESPONSE" | jq -r '.error // empty') +if [ -n "$ERROR" ]; then + echo "groundwire-sign: $ERROR" >&2 + exit 1 +fi + SIGNATURE=$(echo "$RESPONSE" | jq -r '.signature // empty') SIGNER_ID=$(echo "$RESPONSE" | jq -r '.signer_id // empty') PASS=$(echo "$RESPONSE" | jq -r '.pass // empty') +ECASH_TOKENS=$(echo "$RESPONSE" | jq -r '.ecash_tokens // empty') +ECASH_AMOUNT=$(echo "$RESPONSE" | jq -r '.ecash_amount // empty') if [ -z "$SIGNATURE" ]; then echo "groundwire-sign: no signature in response" >&2 @@ -103,13 +160,18 @@ fi # Git captures this verbatim as the gpgsig header. # The CI verifier parses this to extract the signer's @p and signature. -cat < [auth-cookie] +# ./install.sh [auth-cookie] [--maintainer [cookie]] # # This configures git globally to sign all commits with your Groundwire key. # Your ship must be running the %vitriol agent with a signing key configured. # +# When --maintainer is provided, the signing hook will fetch the maintainer's +# ecash pubkey and sats-per-PR price. If a price is set, ecash tokens from +# the committer's wallet are automatically included in the signature. +# # Example: # ./install.sh http://localhost:8080/vitriol "urbauth-~zod=0v5.abc..." +# ./install.sh http://localhost:8080/vitriol "urbauth-~zod=0v5.abc..." \ +# --maintainer http://maintainer:8080/vitriol "urbauth-~nec=0v5.xyz..." # set -euo pipefail @@ -28,6 +34,28 @@ fi ENDPOINT="$1" TOKEN="${2:-}" +# Parse optional --maintainer flag +MAINTAINER_ENDPOINT="" +MAINTAINER_TOKEN="" +shift; shift 2>/dev/null || true +while [ $# -gt 0 ]; do + case "$1" in + --maintainer) + shift + MAINTAINER_ENDPOINT="${1:-}" + shift 2>/dev/null || true + # Next arg is maintainer token if it doesn't start with -- + if [ $# -gt 0 ] && [[ "$1" != --* ]]; then + MAINTAINER_TOKEN="$1" + shift + fi + ;; + *) + shift + ;; + esac +done + chmod +x "$SIGN_PROGRAM" git config --global gpg.program "$SIGN_PROGRAM" @@ -38,9 +66,20 @@ if [ -n "$TOKEN" ]; then git config --global groundwire.sign-token "$TOKEN" fi +if [ -n "$MAINTAINER_ENDPOINT" ]; then + git config --global groundwire.maintainer-endpoint "$MAINTAINER_ENDPOINT" + echo " groundwire.maintainer-endpoint: $MAINTAINER_ENDPOINT" + if [ -n "$MAINTAINER_TOKEN" ]; then + git config --global groundwire.maintainer-token "$MAINTAINER_TOKEN" + fi +fi + echo "Groundwire commit signing configured." echo " gpg.program: $SIGN_PROGRAM" echo " groundwire.sign-endpoint: $ENDPOINT" echo " commit.gpgsign: true" +if [ -n "$MAINTAINER_ENDPOINT" ]; then + echo " groundwire.maintainer-endpoint: $MAINTAINER_ENDPOINT" +fi echo "" echo "All future commits will be signed with your Groundwire key."