gitmark 0.0.71 → 0.0.72

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,7 +7,21 @@
7
7
  "Bash(npm install)",
8
8
  "Bash(npm search txo_parser)",
9
9
  "Bash(../gitmark/bin/git-mark)",
10
- "Bash(../gitmark/bin/git-mark --genesis abc123:0)"
10
+ "Bash(../gitmark/bin/git-mark --genesis abc123:0)",
11
+ "Bash(git config:*)",
12
+ "Bash(git add:*)",
13
+ "Bash(git commit -m ':*)",
14
+ "Bash(git push:*)",
15
+ "Bash(gh pr create --title 'fix: use --local flag in getPrivkey for per-repo keys' --body ':*)",
16
+ "Bash(gh pr:*)",
17
+ "Bash(git checkout:*)",
18
+ "Bash(git pull:*)",
19
+ "Bash(git mark:*)",
20
+ "Bash(gh issue:*)",
21
+ "Bash(node bin/git-mark.js --version)",
22
+ "Bash(node bin/git-mark.js -v)",
23
+ "Bash(npm test:*)",
24
+ "Bash(gh api:*)"
11
25
  ],
12
26
  "deny": [],
13
27
  "ask": [],
@@ -16,4 +30,4 @@
16
30
  "/home/melvin/remote/github.com/solidpayorg/solidpayorg"
17
31
  ]
18
32
  }
19
- }
33
+ }
package/README.md CHANGED
@@ -49,6 +49,8 @@ The chain of addresses on Bitcoin mirrors the chain of commits in git. Anyone ca
49
49
  | `git mark` | Anchor HEAD commit to Bitcoin |
50
50
  | `git mark info` | Show trail state, balance, addresses |
51
51
  | `git mark verify` | Verify all marks against Bitcoin |
52
+ | `git mark update` | Update blocktrails.json from git notes |
53
+ | `git mark --version` | Show version |
52
54
 
53
55
  ## Trail File
54
56
 
@@ -72,16 +74,23 @@ The chain of addresses on Bitcoin mirrors the chain of commits in git. Anyone ca
72
74
  - **states** — commit hashes (input to key derivation)
73
75
  - **txo** — Bitcoin anchors (TXO URIs, self-contained and verifiable)
74
76
 
75
- Private spending state stored in `.git/blocktrails.json` (never committed).
77
+ Private spending state stored in `gitmark.txo` git config (never committed).
76
78
 
77
- ## Key Storage
78
-
79
- Your private key is stored in git config:
79
+ ## Configuration
80
80
 
81
81
  ```bash
82
- git config nostr.privkey <64-char-hex>
82
+ # Private key (auto-generated by init)
83
+ git config --local nostr.privkey <64-char-hex>
84
+
85
+ # Current UTXO (updated on each mark)
86
+ git config --local gitmark.txo txo:tbtc4:txid:vout?amount=X&commit=Y
87
+
88
+ # Dirty flag — set to false to keep working tree clean after marking
89
+ git config --local gitmark.dirty false
83
90
  ```
84
91
 
92
+ When `gitmark.dirty=false`, `git mark` only writes git notes + git config. Run `git mark update` to sync blocktrails.json when ready.
93
+
85
94
  Same secp256k1 key used for Nostr, Bitcoin, and Solid pod authentication.
86
95
 
87
96
  ## Dependencies
package/bin/git-mark.js CHANGED
@@ -8,6 +8,7 @@
8
8
  * git mark [--chain tbtc4]
9
9
  * git mark info
10
10
  * git mark verify
11
+ * git mark update
11
12
  */
12
13
 
13
14
  import { secp256k1, schnorr } from '@noble/curves/secp256k1';
@@ -15,7 +16,11 @@ import { sha256 } from '@noble/hashes/sha256';
15
16
  import { bytesToHex, hexToBytes } from '@noble/hashes/utils';
16
17
  import { execSync } from 'child_process';
17
18
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
18
- import { join } from 'path';
19
+ import { join, dirname } from 'path';
20
+ import { fileURLToPath } from 'url';
21
+
22
+ const __dirname = dirname(fileURLToPath(import.meta.url));
23
+ const PKG = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
19
24
 
20
25
  // --- Constants ---
21
26
  const SECP_N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141n;
@@ -183,9 +188,11 @@ async function broadcastTx(rawHex, explorer) {
183
188
 
184
189
  // --- Git helpers ---
185
190
  function gitExec(cmd) { return execSync(cmd, { encoding: 'utf8' }).trim(); }
186
- function getHead() { return gitExec('git rev-parse HEAD'); }
191
+ function getHead() {
192
+ try { return gitExec('git rev-parse HEAD'); } catch { return null; }
193
+ }
187
194
  function getPrivkey() {
188
- try { return gitExec('git config nostr.privkey'); } catch { return null; }
195
+ try { return gitExec('git config --local nostr.privkey'); } catch { return null; }
189
196
  }
190
197
  function setPrivkey(key) { gitExec(`git config --local nostr.privkey ${key}`); }
191
198
  function isGitRoot() { return existsSync('.git'); }
@@ -199,11 +206,55 @@ function saveTrail(trail) {
199
206
  writeFileSync(TRAIL_FILE, JSON.stringify(trail, null, 2) + '\n');
200
207
  }
201
208
  function loadPrivateState() {
202
- if (!existsSync(PRIVATE_FILE)) return null;
203
- return JSON.parse(readFileSync(PRIVATE_FILE, 'utf8'));
209
+ // Try git config first, fall back to legacy file
210
+ try {
211
+ const txoUri = gitExec('git config --local gitmark.txo');
212
+ const parsed = parseTxoUri(txoUri);
213
+ return { txid: parsed.txid, vout: parsed.vout, amount: parsed.amount };
214
+ } catch {
215
+ if (!existsSync(PRIVATE_FILE)) return null;
216
+ return JSON.parse(readFileSync(PRIVATE_FILE, 'utf8'));
217
+ }
218
+ }
219
+ function savePrivateState(state, chain, head) {
220
+ const txoUri = `txo:${chain}:${state.txid}:${state.vout}?amount=${state.amount}${head ? '&commit=' + head : ''}`;
221
+ gitExec(`git config --local gitmark.txo ${txoUri}`);
222
+ }
223
+ function isDirty() {
224
+ try { return gitExec('git config --local gitmark.dirty') !== 'false'; } catch { return true; }
204
225
  }
205
- function savePrivateState(state) {
206
- writeFileSync(PRIVATE_FILE, JSON.stringify(state, null, 2) + '\n');
226
+ function addGitNote(commitHash, note) {
227
+ try { gitExec(`git notes add -f -m ${note} ${commitHash}`); } catch { /* ignore if no commits */ }
228
+ }
229
+ function loadTrailFromNotes() {
230
+ const trail = loadTrail();
231
+ if (!trail) return null;
232
+ try {
233
+ const notesList = gitExec('git notes list');
234
+ if (!notesList) return trail;
235
+ const notedCommits = new Set(notesList.split('\n').filter(Boolean).map(l => l.split(' ')[1]));
236
+ const allCommits = gitExec('git log --reverse --format=%H').split('\n').filter(Boolean);
237
+ const states = [];
238
+ const txos = [];
239
+ for (const commit of allCommits) {
240
+ if (!notedCommits.has(commit)) continue;
241
+ try {
242
+ const note = gitExec(`git notes show ${commit}`);
243
+ if (note.startsWith('txo:')) {
244
+ states.push(commit);
245
+ txos.push(note);
246
+ }
247
+ } catch { continue; }
248
+ }
249
+ trail.states = states;
250
+ trail.txo = txos;
251
+ return trail;
252
+ } catch {
253
+ return trail;
254
+ }
255
+ }
256
+ function loadFullTrail() {
257
+ return isDirty() ? loadTrail() : loadTrailFromNotes();
207
258
  }
208
259
 
209
260
  // --- Parse TXO URI ---
@@ -243,6 +294,7 @@ async function cmdInit(args) {
243
294
 
244
295
  // Create trail
245
296
  const trail = {
297
+ '@type': 'Blocktrail',
246
298
  version: '0.0.3',
247
299
  profile: 'gitmark',
248
300
  publicKeyBase: pubkey,
@@ -282,7 +334,7 @@ async function cmdInit(args) {
282
334
  );
283
335
  const newTxid = await broadcastTx(rawTx, explorer);
284
336
 
285
- savePrivateState({ txid: newTxid, vout: 0, amount: outputAmount });
337
+ savePrivateState({ txid: newTxid, vout: 0, amount: outputAmount }, chain);
286
338
  console.log(`Funded: ${outputAmount} sats (txid: ${newTxid})`);
287
339
  }
288
340
 
@@ -293,14 +345,14 @@ async function cmdInit(args) {
293
345
  console.log(`Base public key: ${pubkey}`);
294
346
  console.log(`Chain: ${chain}`);
295
347
  console.log(`Address: ${pubkeyToAddress(pubkey, [], chain)}`);
296
- if (!existsSync(PRIVATE_FILE) && voucherIdx === -1) {
348
+ if (!loadPrivateState() && voucherIdx === -1) {
297
349
  console.log(`\nUnfunded. Use: git mark init --voucher txo:${chain}:txid:vout?amount=X&key=Y`);
298
350
  console.log(`Or send sats to: ${pubkeyToAddress(pubkey, [], chain)}`);
299
351
  }
300
352
  }
301
353
 
302
354
  async function cmdMark(args) {
303
- const trail = loadTrail();
355
+ const trail = loadFullTrail();
304
356
  if (!trail) { console.error(`No ${TRAIL_FILE} found. Run: git mark init`); process.exit(1); }
305
357
  const priv = loadPrivateState();
306
358
  if (!priv) { console.error('No funding. Run: git mark init --voucher txo:...'); process.exit(1); }
@@ -309,6 +361,7 @@ async function cmdMark(args) {
309
361
  if (!privkey) { console.error('No private key. Set: git config nostr.privkey <hex>'); process.exit(1); }
310
362
 
311
363
  const head = getHead();
364
+ if (!head) { console.error('No commits yet. Make a commit first.'); process.exit(1); }
312
365
  const chain = trail.chain;
313
366
  const explorer = CHAINS[chain]?.explorer;
314
367
  if (!explorer) { console.error(`Unknown chain: ${chain}`); process.exit(1); }
@@ -345,13 +398,18 @@ async function cmdMark(args) {
345
398
  );
346
399
  const newTxid = await broadcastTx(rawTx, explorer);
347
400
 
348
- // Update trail
349
- trail.states.push(head);
350
- trail.txo.push(`txo:${chain}:${newTxid}:0?commit=${head}`);
351
- saveTrail(trail);
401
+ const txoUri = `txo:${chain}:${newTxid}:0?amount=${outputAmount}&commit=${head}`;
402
+
403
+ // Always: git notes + git config
404
+ addGitNote(head, txoUri);
405
+ savePrivateState({ txid: newTxid, vout: 0, amount: outputAmount }, chain, head);
352
406
 
353
- // Update private state
354
- savePrivateState({ txid: newTxid, vout: 0, amount: outputAmount });
407
+ // Update trail file if dirty mode
408
+ trail.states.push(head);
409
+ trail.txo.push(txoUri);
410
+ if (isDirty()) {
411
+ saveTrail(trail);
412
+ }
355
413
 
356
414
  const address = pubkeyToAddress(trail.publicKeyBase, allStates, chain);
357
415
  console.log(`Marked: ${head.slice(0, 8)} → ${newTxid.slice(0, 16)}...`);
@@ -361,7 +419,7 @@ async function cmdMark(args) {
361
419
  }
362
420
 
363
421
  async function cmdInfo() {
364
- const trail = loadTrail();
422
+ const trail = loadFullTrail();
365
423
  if (!trail) { console.error(`No ${TRAIL_FILE} found.`); process.exit(1); }
366
424
  const priv = loadPrivateState();
367
425
 
@@ -385,7 +443,7 @@ async function cmdInfo() {
385
443
  }
386
444
 
387
445
  async function cmdVerify() {
388
- const trail = loadTrail();
446
+ const trail = loadFullTrail();
389
447
  if (!trail) { console.error(`No ${TRAIL_FILE} found.`); process.exit(1); }
390
448
  if (trail.states.length === 0) { console.log('No marks to verify.'); return; }
391
449
 
@@ -408,7 +466,7 @@ async function cmdVerify() {
408
466
  const out = txData.vout?.[parsed.vout];
409
467
  if (!out) { console.log(` [${i}] FAIL — output ${parsed.vout} not found`); ok = false; continue; }
410
468
  if (out.scriptpubkey_address === expectedAddr) {
411
- console.log(` [${i}] OK — ${trail.states[i].slice(0, 8)} → ${expectedAddr.slice(0, 20)}...`);
469
+ console.log(` [${i}] OK — ${trail.states[i].slice(0, 8)} → ${expectedAddr}`);
412
470
  } else {
413
471
  console.log(` [${i}] FAIL — address mismatch`);
414
472
  console.log(` expected: ${expectedAddr}`);
@@ -425,11 +483,18 @@ async function cmdVerify() {
425
483
  process.exit(ok ? 0 : 1);
426
484
  }
427
485
 
486
+ function cmdUpdate() {
487
+ const trail = loadTrailFromNotes();
488
+ if (!trail) { console.error(`No ${TRAIL_FILE} found. Run: git mark init`); process.exit(1); }
489
+ saveTrail(trail);
490
+ console.log(`Updated ${TRAIL_FILE} from git notes (${trail.states.length} marks)`);
491
+ }
492
+
428
493
  // --- Exports for testing ---
429
494
  export {
430
495
  taggedHash, btScalar, deriveChainedPrivkey, deriveChainedPubkey,
431
496
  pubkeyToAddress, parseTxoUri, p2trScript, buildTransaction,
432
- TRAIL_FILE, PRIVATE_FILE, CHAINS
497
+ TRAIL_FILE, PRIVATE_FILE, CHAINS, isDirty, loadTrailFromNotes, loadFullTrail
433
498
  };
434
499
 
435
500
  // --- CLI ---
@@ -438,12 +503,16 @@ if (isMain) {
438
503
  const args = process.argv.slice(2);
439
504
  const cmd = args[0];
440
505
 
441
- if (cmd === 'init') {
506
+ if (cmd === '--version' || cmd === '-v') {
507
+ console.log(PKG.version);
508
+ } else if (cmd === 'init') {
442
509
  cmdInit(args.slice(1));
443
510
  } else if (cmd === 'info') {
444
511
  cmdInfo();
445
512
  } else if (cmd === 'verify') {
446
513
  cmdVerify();
514
+ } else if (cmd === 'update') {
515
+ cmdUpdate();
447
516
  } else if (cmd === 'mark' || !cmd || (cmd && !cmd.startsWith('-'))) {
448
517
  if (!existsSync(TRAIL_FILE) && cmd !== 'mark') {
449
518
  console.log('Usage:');
@@ -451,6 +520,7 @@ if (isMain) {
451
520
  console.log(' git mark # anchor HEAD to Bitcoin');
452
521
  console.log(' git mark info # show trail state');
453
522
  console.log(' git mark verify # verify trail against Bitcoin');
523
+ console.log(' git mark update # update blocktrails.json from git notes');
454
524
  } else {
455
525
  cmdMark(args.slice(cmd === 'mark' ? 1 : 0));
456
526
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitmark",
3
- "version": "0.0.71",
3
+ "version": "0.0.72",
4
4
  "type": "module",
5
5
  "description": "Anchor git commits to Bitcoin via blocktrails",
6
6
  "main": "bin/git-mark.js",
@@ -5,7 +5,7 @@ import { bytesToHex, hexToBytes } from '@noble/hashes/utils';
5
5
 
6
6
  import {
7
7
  taggedHash, btScalar, deriveChainedPrivkey, deriveChainedPubkey,
8
- pubkeyToAddress, parseTxoUri, p2trScript, CHAINS
8
+ pubkeyToAddress, parseTxoUri, p2trScript, CHAINS, isDirty, loadFullTrail, loadTrailFromNotes
9
9
  } from '../bin/git-mark.js';
10
10
 
11
11
  describe('Key chaining', () => {
@@ -188,4 +188,56 @@ describe('Trail format', () => {
188
188
  };
189
189
  assert.strictEqual(trail.states.length, trail.txo.length);
190
190
  });
191
+
192
+ it('txo URIs include amount and commit params', () => {
193
+ const txoUri = 'txo:tbtc4:abc123:0?amount=9700&commit=deadbeef';
194
+ const parsed = parseTxoUri(txoUri);
195
+ assert.strictEqual(parsed.chain, 'tbtc4');
196
+ assert.strictEqual(parsed.txid, 'abc123');
197
+ assert.strictEqual(parsed.amount, 9700);
198
+ });
199
+ });
200
+
201
+ describe('Dirty flag', () => {
202
+ it('isDirty returns true by default (no config set)', () => {
203
+ assert.strictEqual(isDirty(), true);
204
+ });
205
+
206
+ it('loadFullTrail returns null when no trail file exists', () => {
207
+ const trail = loadFullTrail();
208
+ assert.strictEqual(trail, null);
209
+ });
210
+
211
+ it('loadTrailFromNotes returns null when no trail file exists', () => {
212
+ const trail = loadTrailFromNotes();
213
+ assert.strictEqual(trail, null);
214
+ });
215
+
216
+ it('loadFullTrail uses loadTrail when dirty is true', () => {
217
+ // Default is dirty=true, so loadFullTrail should behave like loadTrail
218
+ const full = loadFullTrail();
219
+ assert.strictEqual(full, null); // no blocktrails.json in test dir
220
+ });
221
+ });
222
+
223
+ describe('TXO URI in git config format', () => {
224
+ it('txo URI with amount and commit is parseable', () => {
225
+ const uri = 'txo:tbtc4:abc123:0?amount=9700&commit=deadbeef';
226
+ const parsed = parseTxoUri(uri);
227
+ assert.strictEqual(parsed.txid, 'abc123');
228
+ assert.strictEqual(parsed.vout, 0);
229
+ assert.strictEqual(parsed.amount, 9700);
230
+ assert.strictEqual(parsed.chain, 'tbtc4');
231
+ });
232
+
233
+ it('txo URI roundtrips through format used by savePrivateState', () => {
234
+ const state = { txid: 'abcdef1234', vout: 0, amount: 14668 };
235
+ const chain = 'tbtc4';
236
+ const head = 'deadbeef';
237
+ const uri = `txo:${chain}:${state.txid}:${state.vout}?amount=${state.amount}&commit=${head}`;
238
+ const parsed = parseTxoUri(uri);
239
+ assert.strictEqual(parsed.txid, state.txid);
240
+ assert.strictEqual(parsed.vout, state.vout);
241
+ assert.strictEqual(parsed.amount, state.amount);
242
+ });
191
243
  });