spike(block2): de-risk social layer — key derivation, OfferTransport, NIP-99 round-trip
Throwaway research spike (spike/ branch, outside pub workspace, no prod deps touched). Validates the open Block 2 decisions before committing a funded round: 1. Deterministic one-way secp256k1 (Nostr) key derived from the Ğ1 root seed via HKDF — user still backs up ONE thing. Reproducible + signs (BIP340). 2. OfferTransport abstraction over Nostr NIP-99 (kind 30402); Offer stays inventory/location-agnostic, geohash coarsened on the wire (tested). 3. publish->discover-by-geohash proven end-to-end against an in-process hermetic mini-relay (~34ms round-trip). Findings + risks + recommendation in docs/design/spike-block2-findings.md. Block 1 suite untouched. 14 tests green, analyzer clean.
This commit is contained in:
parent
6eb1517ffb
commit
49b872b405
16 changed files with 1284 additions and 0 deletions
76
spike/block2_spike/test/derivation_test.dart
Normal file
76
spike/block2_spike/test/derivation_test.dart
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:bip340/bip340.dart' as bip340;
|
||||
import 'package:block2_spike/block2_spike.dart';
|
||||
import 'package:commons_core/commons_core.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
/// Q1 — Identity derivation: a deterministic, one-way secp256k1 (Nostr) key from
|
||||
/// the Duniter/Ğ1-style root seed, so the user still backs up ONE thing.
|
||||
void main() {
|
||||
group('NostrKey.deriveFromSeed', () {
|
||||
// A fixed seed so expectations are reproducible across runs/machines.
|
||||
final seed = Uint8List.fromList(List.generate(32, (i) => i));
|
||||
|
||||
test('is reproducible: same seed → identical key', () {
|
||||
final a = NostrKey.deriveFromSeed(seed);
|
||||
final b = NostrKey.deriveFromSeed(seed);
|
||||
expect(a.privateKeyHex, b.privateKeyHex);
|
||||
expect(a.publicKeyHex, b.publicKeyHex);
|
||||
expect(a.npub, b.npub);
|
||||
});
|
||||
|
||||
test('produces a well-formed x-only key and npub/nsec', () {
|
||||
final key = NostrKey.deriveFromSeed(seed);
|
||||
expect(key.privateKeyHex, matches(RegExp(r'^[0-9a-f]{64}$')));
|
||||
expect(key.publicKeyHex, matches(RegExp(r'^[0-9a-f]{64}$')));
|
||||
// Cross-check: bip340 derives the same pubkey from our private hex.
|
||||
expect(bip340.getPublicKey(key.privateKeyHex), key.publicKeyHex);
|
||||
expect(key.npub, startsWith('npub1'));
|
||||
expect(key.nsec, startsWith('nsec1'));
|
||||
});
|
||||
|
||||
test('the derived key actually signs & verifies (BIP340)', () {
|
||||
final key = NostrKey.deriveFromSeed(seed);
|
||||
final message = 'a' * 64; // 32-byte hex message
|
||||
final sig = bip340.sign(key.privateKeyHex, message, 'b' * 64);
|
||||
expect(bip340.verify(key.publicKeyHex, message, sig), isTrue);
|
||||
});
|
||||
|
||||
test('different seeds → different keys (avalanche on a 1-bit flip)', () {
|
||||
final flipped = Uint8List.fromList(seed)..[0] ^= 0x01;
|
||||
final a = NostrKey.deriveFromSeed(seed);
|
||||
final b = NostrKey.deriveFromSeed(flipped);
|
||||
expect(a.privateKeyHex, isNot(b.privateKeyHex));
|
||||
expect(a.publicKeyHex, isNot(b.publicKeyHex));
|
||||
});
|
||||
|
||||
test('one-wayness: neither key contains the seed, and HKDF is not '
|
||||
'invertible', () {
|
||||
final key = NostrKey.deriveFromSeed(seed);
|
||||
final seedHex = seed
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join();
|
||||
// The seed does not appear verbatim in the derived material.
|
||||
expect(key.privateKeyHex, isNot(contains(seedHex)));
|
||||
expect(key.publicKeyHex, isNot(contains(seedHex)));
|
||||
// Structural argument (documented in findings §1): recovery of the seed
|
||||
// would require inverting HMAC-SHA256. We assert the property we CAN test:
|
||||
// two unrelated seeds never collide to the same key.
|
||||
final other = NostrKey.deriveFromSeed(
|
||||
Uint8List.fromList(List.generate(32, (i) => 255 - i)),
|
||||
);
|
||||
expect(key.privateKeyHex, isNot(other.privateKeyHex));
|
||||
});
|
||||
|
||||
test('integrates with the real IdentityService seed length', () {
|
||||
final rootSeed = IdentityService(
|
||||
random: Random(42),
|
||||
).generateRootSeed();
|
||||
expect(rootSeed.length, IdentityService.rootSeedLengthBytes);
|
||||
final key = NostrKey.deriveFromSeed(rootSeed);
|
||||
expect(key.publicKeyHex, matches(RegExp(r'^[0-9a-f]{64}$')));
|
||||
});
|
||||
});
|
||||
}
|
||||
80
spike/block2_spike/test/privacy_test.dart
Normal file
80
spike/block2_spike/test/privacy_test.dart
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:block2_spike/block2_spike.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
/// Q2 — the `OfferTransport` seam must not leak inventory or an exact location.
|
||||
/// The offer carries only a chosen summary; the wire event is checked byte-wise.
|
||||
void main() {
|
||||
final key = NostrKey.deriveFromSeed(
|
||||
Uint8List.fromList(List.generate(32, (i) => i)),
|
||||
);
|
||||
final codec = Nip99Codec();
|
||||
|
||||
// A realistic private-ish context: the caller ACCIDENTALLY passes a very
|
||||
// precise geohash and there is secret inventory the offer must never expose.
|
||||
const secretInventoryMarker = 'SECRET-LOT-12-KG-IN-SHED';
|
||||
const preciseGeohash = 'u09tunqmabc'; // ~sub-metre precision
|
||||
|
||||
Offer sampleOffer() => Offer(
|
||||
id: 'offer-1',
|
||||
authorPubkeyHex: key.publicKeyHex,
|
||||
summary: 'Tomate rosa de Barbastro',
|
||||
type: OfferType.gift,
|
||||
category: 'Solanaceae',
|
||||
approxGeohash: preciseGeohash,
|
||||
);
|
||||
|
||||
test('exact location is coarsened on the wire (never full precision)', () {
|
||||
final event = codec.encode(sampleOffer(), createdAt: 1000);
|
||||
final gTags = event.tags.where((t) => t[0] == 'g').map((t) => t[1]);
|
||||
// No geohash tag exceeds the coarse cap...
|
||||
expect(gTags.every((g) => g.length <= Nip99Codec.maxGeohashChars), isTrue);
|
||||
// ...and the precise fix never appears anywhere in the serialised event.
|
||||
final wire = jsonEncode(event.toJson());
|
||||
expect(wire, isNot(contains(preciseGeohash)));
|
||||
expect(wire, contains('u09t')); // coarse prefix is present
|
||||
});
|
||||
|
||||
test('no inventory / address field can ride along (seam has none)', () {
|
||||
final event = codec.encode(sampleOffer(), createdAt: 1000);
|
||||
final wire = jsonEncode(event.toJson());
|
||||
expect(wire, isNot(contains(secretInventoryMarker)));
|
||||
// The event has no "location", "address" or "geo-exact" tag by construction.
|
||||
final tagNames = event.tags.map((t) => t[0]).toSet();
|
||||
expect(tagNames, isNot(contains('location')));
|
||||
expect(tagNames, isNot(contains('address')));
|
||||
});
|
||||
|
||||
test('geohash ladder lets an area query match by exact tag', () {
|
||||
final event = codec.encode(sampleOffer(), createdAt: 1000);
|
||||
final gTags = event.tags.where((t) => t[0] == 'g').map((t) => t[1]).toList();
|
||||
expect(gTags, containsAll(['u', 'u0', 'u09', 'u09t']));
|
||||
});
|
||||
|
||||
test('round-trips Offer ↔ NIP-99 event, only chosen fields survive', () {
|
||||
final original = Offer(
|
||||
id: 'offer-2',
|
||||
authorPubkeyHex: key.publicKeyHex,
|
||||
summary: 'Judía del ganxet',
|
||||
type: OfferType.sale,
|
||||
priceAmount: 3,
|
||||
priceCurrency: 'G1',
|
||||
category: 'Fabaceae',
|
||||
approxGeohash: 'sp3e9',
|
||||
);
|
||||
final event = codec.encode(original, createdAt: 1000)
|
||||
..signWith(key.privateKeyHex);
|
||||
expect(event.verify(), isTrue);
|
||||
|
||||
final decoded = codec.decode(event);
|
||||
expect(decoded.id, original.id);
|
||||
expect(decoded.summary, original.summary);
|
||||
expect(decoded.type, OfferType.sale);
|
||||
expect(decoded.priceAmount, 3);
|
||||
expect(decoded.priceCurrency, 'G1');
|
||||
expect(decoded.category, 'Fabaceae');
|
||||
expect(decoded.approxGeohash, 'sp3e9');
|
||||
});
|
||||
}
|
||||
132
spike/block2_spike/test/roundtrip_test.dart
Normal file
132
spike/block2_spike/test/roundtrip_test.dart
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:block2_spike/block2_spike.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
/// Q3 — publish → discover a real offer by geohash against a running relay
|
||||
/// (in-process, hermetic). Proves the flow works end to end and measures it.
|
||||
void main() {
|
||||
late MiniRelay relay;
|
||||
|
||||
setUp(() async => relay = await MiniRelay.start());
|
||||
tearDown(() async => relay.stop());
|
||||
|
||||
NostrKey keyFor(int fill) =>
|
||||
NostrKey.deriveFromSeed(Uint8List(32)..fillRange(0, 32, fill));
|
||||
|
||||
test('offer published by one identity is discovered by another via geohash',
|
||||
() async {
|
||||
final alice = keyFor(1);
|
||||
final bob = keyFor(2);
|
||||
|
||||
final aliceTransport = await NostrOfferTransport.connect(
|
||||
relay.url,
|
||||
privateKeyHex: alice.privateKeyHex,
|
||||
pubkeyHex: alice.publicKeyHex,
|
||||
);
|
||||
final bobTransport = await NostrOfferTransport.connect(
|
||||
relay.url,
|
||||
privateKeyHex: bob.privateKeyHex,
|
||||
pubkeyHex: bob.publicKeyHex,
|
||||
);
|
||||
|
||||
final offer = Offer(
|
||||
id: 'tomate-1',
|
||||
authorPubkeyHex: alice.publicKeyHex,
|
||||
summary: 'Tomate rosa de Barbastro',
|
||||
type: OfferType.gift,
|
||||
category: 'Solanaceae',
|
||||
approxGeohash: 'sp3e9xyz', // will be coarsened to sp3e9
|
||||
);
|
||||
|
||||
final published = await aliceTransport.publish(offer);
|
||||
expect(published.accepted, isTrue,
|
||||
reason: 'relay OK: ${published.message}');
|
||||
|
||||
// Bob queries a coarser area and still finds it (ladder match).
|
||||
final found = await bobTransport.discoverUntilEose(
|
||||
const DiscoveryQuery(geohashPrefix: 'sp3'),
|
||||
);
|
||||
expect(found, hasLength(1));
|
||||
expect(found.single.summary, 'Tomate rosa de Barbastro');
|
||||
expect(found.single.authorPubkeyHex, alice.publicKeyHex);
|
||||
|
||||
await aliceTransport.close();
|
||||
await bobTransport.close();
|
||||
});
|
||||
|
||||
test('a query for a different area returns nothing (geohash scoping works)',
|
||||
() async {
|
||||
final alice = keyFor(1);
|
||||
final t = await NostrOfferTransport.connect(
|
||||
relay.url,
|
||||
privateKeyHex: alice.privateKeyHex,
|
||||
pubkeyHex: alice.publicKeyHex,
|
||||
);
|
||||
await t.publish(Offer(
|
||||
id: 'x',
|
||||
authorPubkeyHex: alice.publicKeyHex,
|
||||
summary: 'Pimiento',
|
||||
type: OfferType.gift,
|
||||
approxGeohash: 'sp3e9',
|
||||
));
|
||||
final elsewhere = await t.discoverUntilEose(
|
||||
const DiscoveryQuery(geohashPrefix: 'u09'),
|
||||
);
|
||||
expect(elsewhere, isEmpty);
|
||||
await t.close();
|
||||
});
|
||||
|
||||
test('re-publishing the same offer id replaces (addressable), not duplicates',
|
||||
() async {
|
||||
final alice = keyFor(3);
|
||||
final t = await NostrOfferTransport.connect(
|
||||
relay.url,
|
||||
privateKeyHex: alice.privateKeyHex,
|
||||
pubkeyHex: alice.publicKeyHex,
|
||||
);
|
||||
Offer o(String summary) => Offer(
|
||||
id: 'lot-42',
|
||||
authorPubkeyHex: alice.publicKeyHex,
|
||||
summary: summary,
|
||||
type: OfferType.exchange,
|
||||
exchangeTerms: 'a cambio de legumbre',
|
||||
approxGeohash: 'sp3e9',
|
||||
);
|
||||
await t.publish(o('v1'));
|
||||
await t.publish(o('v2'));
|
||||
final found = await t.discoverUntilEose(
|
||||
const DiscoveryQuery(geohashPrefix: 'sp3e9'),
|
||||
);
|
||||
expect(found, hasLength(1), reason: 'addressable replace by (kind,pubkey,d)');
|
||||
expect(found.single.summary, 'v2');
|
||||
await t.close();
|
||||
});
|
||||
|
||||
test('measures publish→discover latency (informational)', () async {
|
||||
final alice = keyFor(4);
|
||||
final t = await NostrOfferTransport.connect(
|
||||
relay.url,
|
||||
privateKeyHex: alice.privateKeyHex,
|
||||
pubkeyHex: alice.publicKeyHex,
|
||||
);
|
||||
final sw = Stopwatch()..start();
|
||||
await t.publish(Offer(
|
||||
id: 'latency',
|
||||
authorPubkeyHex: alice.publicKeyHex,
|
||||
summary: 'Calabaza',
|
||||
type: OfferType.gift,
|
||||
approxGeohash: 'sp3e9',
|
||||
));
|
||||
final found = await t.discoverUntilEose(
|
||||
const DiscoveryQuery(geohashPrefix: 'sp3e9'),
|
||||
);
|
||||
sw.stop();
|
||||
expect(found, hasLength(1));
|
||||
// Not an assertion on wall-time (CI is noisy); just surface the number.
|
||||
printOnFailure('publish+discover round-trip: ${sw.elapsedMilliseconds} ms');
|
||||
// ignore: avoid_print
|
||||
print('[metric] publish+discover round-trip: ${sw.elapsedMilliseconds} ms');
|
||||
await t.close();
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue