@toon-protocol/client 0.8.0 → 0.9.0
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.
- package/dist/chunk-5WRI5ZAA.js +31 -0
- package/dist/chunk-5WRI5ZAA.js.map +1 -0
- package/dist/index.d.ts +514 -78
- package/dist/index.js +2080 -99
- package/dist/index.js.map +1 -1
- package/dist/mina-signer-J7GFWOGO.js +6317 -0
- package/dist/mina-signer-J7GFWOGO.js.map +1 -0
- package/package.json +1 -2
package/dist/index.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import "./chunk-5WRI5ZAA.js";
|
|
2
|
+
|
|
1
3
|
// src/ToonClient.ts
|
|
2
4
|
import { generateSecretKey as generateSecretKey2, getPublicKey } from "nostr-tools/pure";
|
|
3
5
|
|
|
@@ -186,6 +188,42 @@ function buildSettlementInfo(config) {
|
|
|
186
188
|
};
|
|
187
189
|
}
|
|
188
190
|
|
|
191
|
+
// src/utils/binary.ts
|
|
192
|
+
function toBase64(bytes) {
|
|
193
|
+
if (typeof Buffer !== "undefined" && Buffer.isBuffer(bytes)) {
|
|
194
|
+
return bytes.toString("base64");
|
|
195
|
+
}
|
|
196
|
+
let binary = "";
|
|
197
|
+
for (const byte of bytes) {
|
|
198
|
+
binary += String.fromCharCode(byte);
|
|
199
|
+
}
|
|
200
|
+
return btoa(binary);
|
|
201
|
+
}
|
|
202
|
+
function fromBase64(base64) {
|
|
203
|
+
if (typeof Buffer !== "undefined" && typeof Buffer.from === "function") {
|
|
204
|
+
return new Uint8Array(Buffer.from(base64, "base64"));
|
|
205
|
+
}
|
|
206
|
+
const binary = atob(base64);
|
|
207
|
+
const bytes = new Uint8Array(binary.length);
|
|
208
|
+
for (let i = 0; i < binary.length; i++) {
|
|
209
|
+
bytes[i] = binary.charCodeAt(i);
|
|
210
|
+
}
|
|
211
|
+
return bytes;
|
|
212
|
+
}
|
|
213
|
+
function toHex(bytes) {
|
|
214
|
+
let hex = "";
|
|
215
|
+
for (const byte of bytes) {
|
|
216
|
+
hex += byte.toString(16).padStart(2, "0");
|
|
217
|
+
}
|
|
218
|
+
return hex;
|
|
219
|
+
}
|
|
220
|
+
function encodeUtf8(str) {
|
|
221
|
+
return new TextEncoder().encode(str);
|
|
222
|
+
}
|
|
223
|
+
function isBase64(str) {
|
|
224
|
+
return /^[A-Za-z0-9+/]*={0,2}$/.test(str);
|
|
225
|
+
}
|
|
226
|
+
|
|
189
227
|
// src/modes/http.ts
|
|
190
228
|
import { BootstrapService, createDiscoveryTracker } from "@toon-protocol/core";
|
|
191
229
|
|
|
@@ -287,8 +325,7 @@ var HttpRuntimeClient = class {
|
|
|
287
325
|
throw new ValidationError("Data cannot be empty");
|
|
288
326
|
}
|
|
289
327
|
try {
|
|
290
|
-
|
|
291
|
-
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(params.data)) {
|
|
328
|
+
if (!isBase64(params.data)) {
|
|
292
329
|
throw new ValidationError(
|
|
293
330
|
`Data must be valid Base64 encoding: "${params.data}"`
|
|
294
331
|
);
|
|
@@ -378,33 +415,474 @@ var HttpRuntimeClient = class {
|
|
|
378
415
|
}
|
|
379
416
|
};
|
|
380
417
|
|
|
381
|
-
// src/
|
|
382
|
-
|
|
383
|
-
var
|
|
384
|
-
|
|
385
|
-
|
|
418
|
+
// src/btp/protocol.ts
|
|
419
|
+
var textEncoder = new TextEncoder();
|
|
420
|
+
var textDecoder = new TextDecoder();
|
|
421
|
+
var BTPMessageType = {
|
|
422
|
+
RESPONSE: 1,
|
|
423
|
+
ERROR: 2,
|
|
424
|
+
MESSAGE: 6
|
|
386
425
|
};
|
|
387
|
-
|
|
388
|
-
const noop = (..._args) => {
|
|
389
|
-
};
|
|
390
|
-
const logger = {
|
|
391
|
-
level: "info",
|
|
392
|
-
silent: noop,
|
|
393
|
-
info: console.info.bind(console),
|
|
394
|
-
warn: console.warn.bind(console),
|
|
395
|
-
error: console.error.bind(console),
|
|
396
|
-
debug: console.debug.bind(console),
|
|
397
|
-
trace: console.debug.bind(console),
|
|
398
|
-
fatal: console.error.bind(console),
|
|
399
|
-
child: () => createConsoleLogger()
|
|
400
|
-
};
|
|
401
|
-
return logger;
|
|
402
|
-
}
|
|
403
|
-
var ILP_PACKET_TYPE = {
|
|
426
|
+
var ILPPacketType = {
|
|
404
427
|
PREPARE: 12,
|
|
405
428
|
FULFILL: 13,
|
|
406
429
|
REJECT: 14
|
|
407
430
|
};
|
|
431
|
+
function concat(...arrays) {
|
|
432
|
+
const totalLength = arrays.reduce((sum, a) => sum + a.length, 0);
|
|
433
|
+
const result = new Uint8Array(totalLength);
|
|
434
|
+
let offset = 0;
|
|
435
|
+
for (const a of arrays) {
|
|
436
|
+
result.set(a, offset);
|
|
437
|
+
offset += a.length;
|
|
438
|
+
}
|
|
439
|
+
return result;
|
|
440
|
+
}
|
|
441
|
+
function readUint8(buf, offset) {
|
|
442
|
+
if (offset >= buf.length) throw new Error("Buffer underflow reading uint8");
|
|
443
|
+
return buf[offset];
|
|
444
|
+
}
|
|
445
|
+
function readUint16BE(buf, offset) {
|
|
446
|
+
if (offset + 2 > buf.length)
|
|
447
|
+
throw new Error("Buffer underflow reading uint16");
|
|
448
|
+
return buf[offset] << 8 | buf[offset + 1];
|
|
449
|
+
}
|
|
450
|
+
function readUint32BE(buf, offset) {
|
|
451
|
+
if (offset + 4 > buf.length)
|
|
452
|
+
throw new Error("Buffer underflow reading uint32");
|
|
453
|
+
return (buf[offset] << 24 | buf[offset + 1] << 16 | buf[offset + 2] << 8 | buf[offset + 3]) >>> 0;
|
|
454
|
+
}
|
|
455
|
+
function writeUint8(value) {
|
|
456
|
+
return new Uint8Array([value]);
|
|
457
|
+
}
|
|
458
|
+
function writeUint16BE(value) {
|
|
459
|
+
return new Uint8Array([value >> 8 & 255, value & 255]);
|
|
460
|
+
}
|
|
461
|
+
function writeUint32BE(value) {
|
|
462
|
+
return new Uint8Array([
|
|
463
|
+
value >> 24 & 255,
|
|
464
|
+
value >> 16 & 255,
|
|
465
|
+
value >> 8 & 255,
|
|
466
|
+
value & 255
|
|
467
|
+
]);
|
|
468
|
+
}
|
|
469
|
+
function sliceUtf8(buf, offset, length) {
|
|
470
|
+
return textDecoder.decode(buf.slice(offset, offset + length));
|
|
471
|
+
}
|
|
472
|
+
function encodeVarUInt(value) {
|
|
473
|
+
if (value >= 0n && value <= 127n) {
|
|
474
|
+
return new Uint8Array([Number(value)]);
|
|
475
|
+
}
|
|
476
|
+
const bytes = [];
|
|
477
|
+
let remaining = value;
|
|
478
|
+
while (remaining > 0n) {
|
|
479
|
+
bytes.unshift(Number(remaining & 0xffn));
|
|
480
|
+
remaining = remaining >> 8n;
|
|
481
|
+
}
|
|
482
|
+
return new Uint8Array([128 | bytes.length, ...bytes]);
|
|
483
|
+
}
|
|
484
|
+
function decodeVarUInt(buf, offset) {
|
|
485
|
+
const firstByte = readUint8(buf, offset);
|
|
486
|
+
if (firstByte <= 127) {
|
|
487
|
+
return { value: BigInt(firstByte), bytesRead: 1 };
|
|
488
|
+
}
|
|
489
|
+
const length = firstByte & 127;
|
|
490
|
+
if (offset + 1 + length > buf.length)
|
|
491
|
+
throw new Error("VarUInt buffer underflow");
|
|
492
|
+
let value = 0n;
|
|
493
|
+
for (let i = 0; i < length; i++) {
|
|
494
|
+
value = value << 8n | BigInt(buf[offset + 1 + i]);
|
|
495
|
+
}
|
|
496
|
+
return { value, bytesRead: 1 + length };
|
|
497
|
+
}
|
|
498
|
+
function encodeVarOctetString(data) {
|
|
499
|
+
return concat(encodeVarUInt(BigInt(data.length)), data);
|
|
500
|
+
}
|
|
501
|
+
function decodeVarOctetString(buf, offset) {
|
|
502
|
+
const { value: length, bytesRead: lenBytes } = decodeVarUInt(buf, offset);
|
|
503
|
+
const dataLen = Number(length);
|
|
504
|
+
const start = offset + lenBytes;
|
|
505
|
+
if (start + dataLen > buf.length)
|
|
506
|
+
throw new Error("VarOctetString buffer underflow");
|
|
507
|
+
return {
|
|
508
|
+
value: buf.slice(start, start + dataLen),
|
|
509
|
+
bytesRead: lenBytes + dataLen
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
function encodeGeneralizedTime(date) {
|
|
513
|
+
const y = date.getUTCFullYear().toString().padStart(4, "0");
|
|
514
|
+
const mo = (date.getUTCMonth() + 1).toString().padStart(2, "0");
|
|
515
|
+
const d = date.getUTCDate().toString().padStart(2, "0");
|
|
516
|
+
const h = date.getUTCHours().toString().padStart(2, "0");
|
|
517
|
+
const mi = date.getUTCMinutes().toString().padStart(2, "0");
|
|
518
|
+
const s = date.getUTCSeconds().toString().padStart(2, "0");
|
|
519
|
+
const ms = date.getUTCMilliseconds().toString().padStart(3, "0");
|
|
520
|
+
return textEncoder.encode(`${y}${mo}${d}${h}${mi}${s}.${ms}Z`);
|
|
521
|
+
}
|
|
522
|
+
function serializeIlpPrepare(packet) {
|
|
523
|
+
const condition = packet.executionCondition.length === 32 ? packet.executionCondition : new Uint8Array(32);
|
|
524
|
+
return concat(
|
|
525
|
+
writeUint8(ILPPacketType.PREPARE),
|
|
526
|
+
encodeVarUInt(packet.amount),
|
|
527
|
+
encodeGeneralizedTime(packet.expiresAt),
|
|
528
|
+
condition,
|
|
529
|
+
encodeVarOctetString(textEncoder.encode(packet.destination)),
|
|
530
|
+
encodeVarOctetString(packet.data)
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
function deserializeIlpPacket(buf) {
|
|
534
|
+
if (buf.length === 0) throw new Error("Empty ILP packet");
|
|
535
|
+
const type = buf[0];
|
|
536
|
+
if (type === ILPPacketType.FULFILL) return deserializeIlpFulfill(buf);
|
|
537
|
+
if (type === ILPPacketType.REJECT) return deserializeIlpReject(buf);
|
|
538
|
+
throw new Error(`Unknown ILP packet type: ${type}`);
|
|
539
|
+
}
|
|
540
|
+
function deserializeIlpFulfill(buf) {
|
|
541
|
+
let offset = 1;
|
|
542
|
+
offset += 32;
|
|
543
|
+
const { value: data } = decodeVarOctetString(buf, offset);
|
|
544
|
+
return { type: ILPPacketType.FULFILL, data };
|
|
545
|
+
}
|
|
546
|
+
function deserializeIlpReject(buf) {
|
|
547
|
+
let offset = 1;
|
|
548
|
+
const code = sliceUtf8(buf, offset, 3);
|
|
549
|
+
offset += 3;
|
|
550
|
+
const { bytesRead: tbBytes } = decodeVarOctetString(buf, offset);
|
|
551
|
+
offset += tbBytes;
|
|
552
|
+
const { value: msgBuf, bytesRead: msgBytes } = decodeVarOctetString(
|
|
553
|
+
buf,
|
|
554
|
+
offset
|
|
555
|
+
);
|
|
556
|
+
offset += msgBytes;
|
|
557
|
+
const message = textDecoder.decode(msgBuf);
|
|
558
|
+
const { value: data } = decodeVarOctetString(buf, offset);
|
|
559
|
+
return { type: ILPPacketType.REJECT, code, message, data };
|
|
560
|
+
}
|
|
561
|
+
function serializeBtpMessage(message) {
|
|
562
|
+
const parts = [
|
|
563
|
+
writeUint8(message.type),
|
|
564
|
+
writeUint32BE(message.requestId)
|
|
565
|
+
];
|
|
566
|
+
const data = message.data;
|
|
567
|
+
const protocolData = data.protocolData ?? [];
|
|
568
|
+
parts.push(writeUint8(protocolData.length));
|
|
569
|
+
for (const pd of protocolData) {
|
|
570
|
+
const nameBytes = textEncoder.encode(pd.protocolName);
|
|
571
|
+
parts.push(writeUint8(nameBytes.length));
|
|
572
|
+
parts.push(nameBytes);
|
|
573
|
+
parts.push(writeUint16BE(pd.contentType));
|
|
574
|
+
parts.push(writeUint32BE(pd.data.length));
|
|
575
|
+
if (pd.data.length > 0) parts.push(pd.data);
|
|
576
|
+
}
|
|
577
|
+
const ilpPacket = data.ilpPacket ?? new Uint8Array(0);
|
|
578
|
+
parts.push(writeUint32BE(ilpPacket.length));
|
|
579
|
+
if (ilpPacket.length > 0) parts.push(ilpPacket);
|
|
580
|
+
return concat(...parts);
|
|
581
|
+
}
|
|
582
|
+
function parseBtpMessage(buf) {
|
|
583
|
+
if (buf.length < 5) throw new Error("BTP message too short");
|
|
584
|
+
let offset = 0;
|
|
585
|
+
const type = readUint8(buf, offset);
|
|
586
|
+
offset += 1;
|
|
587
|
+
const requestId = readUint32BE(buf, offset);
|
|
588
|
+
offset += 4;
|
|
589
|
+
if (type === BTPMessageType.ERROR) {
|
|
590
|
+
const codeLen = readUint8(buf, offset);
|
|
591
|
+
offset += 1;
|
|
592
|
+
const code = sliceUtf8(buf, offset, codeLen);
|
|
593
|
+
offset += codeLen;
|
|
594
|
+
const nameLen = readUint8(buf, offset);
|
|
595
|
+
offset += 1;
|
|
596
|
+
const name = sliceUtf8(buf, offset, nameLen);
|
|
597
|
+
offset += nameLen;
|
|
598
|
+
const taLen = readUint8(buf, offset);
|
|
599
|
+
offset += 1;
|
|
600
|
+
const triggeredAt = sliceUtf8(buf, offset, taLen);
|
|
601
|
+
offset += taLen;
|
|
602
|
+
const dataLen = readUint32BE(buf, offset);
|
|
603
|
+
offset += 4;
|
|
604
|
+
const data = buf.slice(offset, offset + dataLen);
|
|
605
|
+
return { type, requestId, data: { code, name, triggeredAt, data } };
|
|
606
|
+
}
|
|
607
|
+
const pdCount = readUint8(buf, offset);
|
|
608
|
+
offset += 1;
|
|
609
|
+
const protocolData = [];
|
|
610
|
+
for (let i = 0; i < pdCount; i++) {
|
|
611
|
+
const nameLen = readUint8(buf, offset);
|
|
612
|
+
offset += 1;
|
|
613
|
+
const protocolName = sliceUtf8(buf, offset, nameLen);
|
|
614
|
+
offset += nameLen;
|
|
615
|
+
const contentType = readUint16BE(buf, offset);
|
|
616
|
+
offset += 2;
|
|
617
|
+
const dataLen = readUint32BE(buf, offset);
|
|
618
|
+
offset += 4;
|
|
619
|
+
const data = buf.slice(offset, offset + dataLen);
|
|
620
|
+
offset += dataLen;
|
|
621
|
+
protocolData.push({ protocolName, contentType, data });
|
|
622
|
+
}
|
|
623
|
+
let ilpPacket;
|
|
624
|
+
if (offset + 4 <= buf.length) {
|
|
625
|
+
const ilpLen = readUint32BE(buf, offset);
|
|
626
|
+
offset += 4;
|
|
627
|
+
if (ilpLen > 0 && offset + ilpLen <= buf.length) {
|
|
628
|
+
ilpPacket = buf.slice(offset, offset + ilpLen);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
return { type, requestId, data: { protocolData, ilpPacket } };
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// src/btp/IsomorphicBtpClient.ts
|
|
635
|
+
var textEncoder2 = new TextEncoder();
|
|
636
|
+
var BtpConnectionError = class extends Error {
|
|
637
|
+
constructor(message) {
|
|
638
|
+
super(message);
|
|
639
|
+
this.name = "BtpConnectionError";
|
|
640
|
+
}
|
|
641
|
+
};
|
|
642
|
+
var BtpAuthError = class extends Error {
|
|
643
|
+
constructor(message) {
|
|
644
|
+
super(message);
|
|
645
|
+
this.name = "BtpAuthError";
|
|
646
|
+
}
|
|
647
|
+
};
|
|
648
|
+
var IsomorphicBtpClient = class {
|
|
649
|
+
ws = null;
|
|
650
|
+
_isConnected = false;
|
|
651
|
+
requestIdCounter = 0;
|
|
652
|
+
pendingRequests = /* @__PURE__ */ new Map();
|
|
653
|
+
config;
|
|
654
|
+
constructor(config) {
|
|
655
|
+
this.config = {
|
|
656
|
+
sendTimeoutMs: 3e4,
|
|
657
|
+
authTimeoutMs: 5e3,
|
|
658
|
+
...config
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
get isConnected() {
|
|
662
|
+
return this._isConnected;
|
|
663
|
+
}
|
|
664
|
+
async connect() {
|
|
665
|
+
if (this._isConnected) return;
|
|
666
|
+
return new Promise((resolve, reject) => {
|
|
667
|
+
try {
|
|
668
|
+
this.ws = new WebSocket(this.config.url);
|
|
669
|
+
this.ws.binaryType = "arraybuffer";
|
|
670
|
+
} catch (err) {
|
|
671
|
+
reject(
|
|
672
|
+
new BtpConnectionError(
|
|
673
|
+
`Failed to create WebSocket: ${err instanceof Error ? err.message : String(err)}`
|
|
674
|
+
)
|
|
675
|
+
);
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
this.ws.onopen = async () => {
|
|
679
|
+
try {
|
|
680
|
+
await this.authenticate();
|
|
681
|
+
this._isConnected = true;
|
|
682
|
+
resolve();
|
|
683
|
+
} catch (err) {
|
|
684
|
+
this._isConnected = false;
|
|
685
|
+
this.ws?.close();
|
|
686
|
+
reject(err);
|
|
687
|
+
}
|
|
688
|
+
};
|
|
689
|
+
this.ws.onmessage = (event) => {
|
|
690
|
+
this.handleMessage(event.data);
|
|
691
|
+
};
|
|
692
|
+
this.ws.onerror = () => {
|
|
693
|
+
reject(new BtpConnectionError("WebSocket connection error"));
|
|
694
|
+
};
|
|
695
|
+
this.ws.onclose = () => {
|
|
696
|
+
this._isConnected = false;
|
|
697
|
+
for (const [id, pending] of this.pendingRequests) {
|
|
698
|
+
clearTimeout(pending.timeoutId);
|
|
699
|
+
pending.reject(new BtpConnectionError("Connection closed"));
|
|
700
|
+
this.pendingRequests.delete(id);
|
|
701
|
+
}
|
|
702
|
+
};
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
async disconnect() {
|
|
706
|
+
this._isConnected = false;
|
|
707
|
+
if (this.ws) {
|
|
708
|
+
this.ws.close();
|
|
709
|
+
this.ws = null;
|
|
710
|
+
}
|
|
711
|
+
for (const [id, pending] of this.pendingRequests) {
|
|
712
|
+
clearTimeout(pending.timeoutId);
|
|
713
|
+
pending.reject(new BtpConnectionError("Disconnected"));
|
|
714
|
+
this.pendingRequests.delete(id);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
/**
|
|
718
|
+
* Send an ILP PREPARE packet, optionally with protocol data (e.g. payment channel claim).
|
|
719
|
+
* Returns the ILP response (FULFILL or REJECT).
|
|
720
|
+
*/
|
|
721
|
+
async sendPacket(packet, protocolData) {
|
|
722
|
+
if (!this._isConnected || !this.ws) {
|
|
723
|
+
throw new BtpConnectionError("Not connected");
|
|
724
|
+
}
|
|
725
|
+
const serializedIlp = serializeIlpPrepare(packet);
|
|
726
|
+
const requestId = this.nextRequestId();
|
|
727
|
+
const btpMessage = serializeBtpMessage({
|
|
728
|
+
type: BTPMessageType.MESSAGE,
|
|
729
|
+
requestId,
|
|
730
|
+
data: {
|
|
731
|
+
protocolData: protocolData ?? [],
|
|
732
|
+
ilpPacket: serializedIlp
|
|
733
|
+
}
|
|
734
|
+
});
|
|
735
|
+
this.ws.send(btpMessage);
|
|
736
|
+
let timeoutMs = this.config.sendTimeoutMs;
|
|
737
|
+
if (packet.expiresAt) {
|
|
738
|
+
const remaining = packet.expiresAt.getTime() - Date.now();
|
|
739
|
+
timeoutMs = Math.max(remaining - 500, 1e3);
|
|
740
|
+
}
|
|
741
|
+
return new Promise((resolve, reject) => {
|
|
742
|
+
const timeoutId = setTimeout(() => {
|
|
743
|
+
this.pendingRequests.delete(requestId);
|
|
744
|
+
reject(new BtpConnectionError(`Packet send timeout (${timeoutMs}ms)`));
|
|
745
|
+
}, timeoutMs);
|
|
746
|
+
this.pendingRequests.set(requestId, { resolve, reject, timeoutId });
|
|
747
|
+
});
|
|
748
|
+
}
|
|
749
|
+
// ─── Private ────────────────────────────────────────────────────────────
|
|
750
|
+
async authenticate() {
|
|
751
|
+
if (!this.ws) throw new BtpAuthError("WebSocket not connected");
|
|
752
|
+
const authData = JSON.stringify({
|
|
753
|
+
peerId: this.config.peerId,
|
|
754
|
+
secret: this.config.authToken
|
|
755
|
+
});
|
|
756
|
+
const requestId = this.nextRequestId();
|
|
757
|
+
const authMessage = serializeBtpMessage({
|
|
758
|
+
type: BTPMessageType.MESSAGE,
|
|
759
|
+
requestId,
|
|
760
|
+
data: {
|
|
761
|
+
protocolData: [
|
|
762
|
+
{
|
|
763
|
+
protocolName: "auth",
|
|
764
|
+
contentType: 0,
|
|
765
|
+
data: textEncoder2.encode(authData)
|
|
766
|
+
}
|
|
767
|
+
],
|
|
768
|
+
ilpPacket: new Uint8Array(0)
|
|
769
|
+
}
|
|
770
|
+
});
|
|
771
|
+
return new Promise((resolve, reject) => {
|
|
772
|
+
const timeout = setTimeout(() => {
|
|
773
|
+
reject(new BtpAuthError("Authentication timeout"));
|
|
774
|
+
}, this.config.authTimeoutMs);
|
|
775
|
+
const originalHandler = this.ws.onmessage;
|
|
776
|
+
this.ws.onmessage = (event) => {
|
|
777
|
+
try {
|
|
778
|
+
const data = this.toUint8Array(event.data);
|
|
779
|
+
try {
|
|
780
|
+
const jsonStr = new TextDecoder().decode(data);
|
|
781
|
+
if (jsonStr.startsWith("{")) {
|
|
782
|
+
}
|
|
783
|
+
} catch {
|
|
784
|
+
}
|
|
785
|
+
const message = parseBtpMessage(data);
|
|
786
|
+
if (message.requestId === requestId) {
|
|
787
|
+
clearTimeout(timeout);
|
|
788
|
+
this.ws.onmessage = originalHandler;
|
|
789
|
+
if (message.type === BTPMessageType.ERROR) {
|
|
790
|
+
const errData = message.data;
|
|
791
|
+
reject(
|
|
792
|
+
new BtpAuthError(`Authentication failed: ${errData.code}`)
|
|
793
|
+
);
|
|
794
|
+
} else if (message.type === BTPMessageType.RESPONSE) {
|
|
795
|
+
resolve();
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
} catch (err) {
|
|
799
|
+
clearTimeout(timeout);
|
|
800
|
+
this.ws.onmessage = originalHandler;
|
|
801
|
+
reject(
|
|
802
|
+
new BtpAuthError(err instanceof Error ? err.message : String(err))
|
|
803
|
+
);
|
|
804
|
+
}
|
|
805
|
+
};
|
|
806
|
+
this.ws.send(authMessage);
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
handleMessage(raw) {
|
|
810
|
+
try {
|
|
811
|
+
const data = this.toUint8Array(raw);
|
|
812
|
+
const jsonStr = new TextDecoder().decode(data);
|
|
813
|
+
if (jsonStr.startsWith("{")) {
|
|
814
|
+
const json = JSON.parse(jsonStr);
|
|
815
|
+
if (json["type"] === "FULFILL" || json["type"] === "REJECT") {
|
|
816
|
+
const first = this.pendingRequests.entries().next();
|
|
817
|
+
if (!first.done) {
|
|
818
|
+
const [id, pending] = first.value;
|
|
819
|
+
clearTimeout(pending.timeoutId);
|
|
820
|
+
this.pendingRequests.delete(id);
|
|
821
|
+
if (json["type"] === "FULFILL") {
|
|
822
|
+
const responseData = json["data"] ? this.base64ToUint8Array(json["data"]) : new Uint8Array(0);
|
|
823
|
+
pending.resolve({
|
|
824
|
+
type: ILPPacketType.FULFILL,
|
|
825
|
+
data: responseData
|
|
826
|
+
});
|
|
827
|
+
} else {
|
|
828
|
+
pending.resolve({
|
|
829
|
+
type: ILPPacketType.REJECT,
|
|
830
|
+
code: json["code"] || "F00",
|
|
831
|
+
message: json["message"] || "Unknown error",
|
|
832
|
+
data: json["data"] ? this.base64ToUint8Array(json["data"]) : new Uint8Array(0)
|
|
833
|
+
});
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
} catch {
|
|
840
|
+
}
|
|
841
|
+
try {
|
|
842
|
+
const data = this.toUint8Array(raw);
|
|
843
|
+
const message = parseBtpMessage(data);
|
|
844
|
+
if (message.type === BTPMessageType.RESPONSE || message.type === BTPMessageType.ERROR) {
|
|
845
|
+
const pending = this.pendingRequests.get(message.requestId);
|
|
846
|
+
if (!pending) return;
|
|
847
|
+
clearTimeout(pending.timeoutId);
|
|
848
|
+
this.pendingRequests.delete(message.requestId);
|
|
849
|
+
if (message.type === BTPMessageType.ERROR) {
|
|
850
|
+
const errData = message.data;
|
|
851
|
+
pending.reject(
|
|
852
|
+
new BtpConnectionError(`BTP error: ${errData.code} ${errData.name}`)
|
|
853
|
+
);
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
const msgData = message.data;
|
|
857
|
+
if (msgData.ilpPacket && msgData.ilpPacket.length > 0) {
|
|
858
|
+
const ilpResponse = deserializeIlpPacket(msgData.ilpPacket);
|
|
859
|
+
pending.resolve(ilpResponse);
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
} catch {
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
toUint8Array(data) {
|
|
866
|
+
if (data instanceof ArrayBuffer) return new Uint8Array(data);
|
|
867
|
+
if (data instanceof Uint8Array) return data;
|
|
868
|
+
if (typeof data === "string") return textEncoder2.encode(data);
|
|
869
|
+
throw new Error(`Unexpected WebSocket data type: ${typeof data}`);
|
|
870
|
+
}
|
|
871
|
+
base64ToUint8Array(base64) {
|
|
872
|
+
const binary = atob(base64);
|
|
873
|
+
const bytes = new Uint8Array(binary.length);
|
|
874
|
+
for (let i = 0; i < binary.length; i++) {
|
|
875
|
+
bytes[i] = binary.charCodeAt(i);
|
|
876
|
+
}
|
|
877
|
+
return bytes;
|
|
878
|
+
}
|
|
879
|
+
nextRequestId() {
|
|
880
|
+
this.requestIdCounter = this.requestIdCounter + 1 & 4294967295;
|
|
881
|
+
return this.requestIdCounter;
|
|
882
|
+
}
|
|
883
|
+
};
|
|
884
|
+
|
|
885
|
+
// src/adapters/BtpRuntimeClient.ts
|
|
408
886
|
function isConnectionError(error) {
|
|
409
887
|
const msg = error.message.toLowerCase();
|
|
410
888
|
return msg.includes("not connected") || msg.includes("connection") || msg.includes("websocket") || msg.includes("econnrefused") || msg.includes("econnreset") || msg.includes("socket hang up") || msg.includes("timeout");
|
|
@@ -413,33 +891,23 @@ var BtpRuntimeClient = class {
|
|
|
413
891
|
btpClient = null;
|
|
414
892
|
config;
|
|
415
893
|
_isConnected = false;
|
|
416
|
-
logger;
|
|
417
894
|
constructor(config) {
|
|
418
895
|
this.config = config;
|
|
419
|
-
this.logger = config.logger ?? createConsoleLogger();
|
|
420
896
|
}
|
|
421
897
|
/**
|
|
422
898
|
* Connects to the BTP peer via WebSocket.
|
|
423
899
|
*/
|
|
424
900
|
async connect() {
|
|
425
|
-
|
|
426
|
-
id: this.config.peerId,
|
|
901
|
+
this.btpClient = new IsomorphicBtpClient({
|
|
427
902
|
url: this.config.btpUrl,
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
};
|
|
432
|
-
this.btpClient = new BTPClient(
|
|
433
|
-
peer,
|
|
434
|
-
this.config.peerId,
|
|
435
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
436
|
-
this.logger
|
|
437
|
-
);
|
|
903
|
+
peerId: this.config.peerId,
|
|
904
|
+
authToken: this.config.authToken
|
|
905
|
+
});
|
|
438
906
|
await this.btpClient.connect();
|
|
439
907
|
this._isConnected = true;
|
|
440
908
|
}
|
|
441
909
|
/**
|
|
442
|
-
* Attempts to reconnect by creating a fresh
|
|
910
|
+
* Attempts to reconnect by creating a fresh client and connecting.
|
|
443
911
|
*/
|
|
444
912
|
async reconnect() {
|
|
445
913
|
if (this.btpClient) {
|
|
@@ -450,7 +918,6 @@ var BtpRuntimeClient = class {
|
|
|
450
918
|
this.btpClient = null;
|
|
451
919
|
this._isConnected = false;
|
|
452
920
|
}
|
|
453
|
-
this.logger.info("[BtpRuntimeClient] Reconnecting...");
|
|
454
921
|
await this.connect();
|
|
455
922
|
}
|
|
456
923
|
/**
|
|
@@ -503,73 +970,66 @@ var BtpRuntimeClient = class {
|
|
|
503
970
|
if (!this._isConnected) {
|
|
504
971
|
await this.reconnect();
|
|
505
972
|
}
|
|
506
|
-
const
|
|
507
|
-
type:
|
|
973
|
+
const response = await this.btpClient.sendPacket({
|
|
974
|
+
type: 12,
|
|
508
975
|
amount: BigInt(params.amount),
|
|
509
976
|
destination: params.destination,
|
|
510
|
-
executionCondition:
|
|
977
|
+
executionCondition: new Uint8Array(32),
|
|
511
978
|
expiresAt: new Date(Date.now() + (params.timeout ?? 3e4)),
|
|
512
|
-
data:
|
|
513
|
-
};
|
|
514
|
-
|
|
515
|
-
if (!response) {
|
|
516
|
-
throw new Error("BTP client not connected");
|
|
517
|
-
}
|
|
518
|
-
if (response.type === ILP_PACKET_TYPE.FULFILL) {
|
|
519
|
-
const fulfill = response;
|
|
979
|
+
data: fromBase64(params.data)
|
|
980
|
+
});
|
|
981
|
+
if (response.type === ILPPacketType.FULFILL) {
|
|
520
982
|
return {
|
|
521
983
|
accepted: true,
|
|
522
|
-
data:
|
|
984
|
+
data: response.data.length > 0 ? toBase64(response.data) : void 0
|
|
523
985
|
};
|
|
524
986
|
}
|
|
525
|
-
const reject = response;
|
|
526
987
|
return {
|
|
527
988
|
accepted: false,
|
|
528
|
-
code:
|
|
529
|
-
message:
|
|
530
|
-
data:
|
|
989
|
+
code: response.code,
|
|
990
|
+
message: response.message,
|
|
991
|
+
data: response.data.length > 0 ? toBase64(response.data) : void 0
|
|
531
992
|
};
|
|
532
993
|
}
|
|
533
994
|
/**
|
|
534
995
|
* Single-attempt claim + ILP packet send. Reconnects if not connected.
|
|
535
|
-
* Embeds the claim in the same BTP message as the ILP PREPARE packet.
|
|
536
996
|
*/
|
|
537
997
|
async _sendIlpPacketWithClaimOnce(params, claim) {
|
|
538
998
|
if (!this._isConnected) {
|
|
539
999
|
await this.reconnect();
|
|
540
1000
|
}
|
|
541
1001
|
if (!this.btpClient) {
|
|
542
|
-
throw new
|
|
1002
|
+
throw new BtpConnectionError("BTP client not connected");
|
|
543
1003
|
}
|
|
544
|
-
const packet = {
|
|
545
|
-
type: ILP_PACKET_TYPE.PREPARE,
|
|
546
|
-
amount: BigInt(params.amount),
|
|
547
|
-
destination: params.destination,
|
|
548
|
-
executionCondition: Buffer.alloc(32),
|
|
549
|
-
expiresAt: new Date(Date.now() + (params.timeout ?? 3e4)),
|
|
550
|
-
data: Buffer.from(params.data, "base64")
|
|
551
|
-
};
|
|
552
1004
|
const protocolData = [
|
|
553
1005
|
{
|
|
554
|
-
protocolName:
|
|
555
|
-
contentType:
|
|
556
|
-
data:
|
|
1006
|
+
protocolName: "payment-channel-claim",
|
|
1007
|
+
contentType: 1,
|
|
1008
|
+
data: encodeUtf8(JSON.stringify(claim))
|
|
557
1009
|
}
|
|
558
1010
|
];
|
|
559
|
-
const response = await this.btpClient.sendPacket(
|
|
560
|
-
|
|
561
|
-
|
|
1011
|
+
const response = await this.btpClient.sendPacket(
|
|
1012
|
+
{
|
|
1013
|
+
type: 12,
|
|
1014
|
+
amount: BigInt(params.amount),
|
|
1015
|
+
destination: params.destination,
|
|
1016
|
+
executionCondition: new Uint8Array(32),
|
|
1017
|
+
expiresAt: new Date(Date.now() + (params.timeout ?? 3e4)),
|
|
1018
|
+
data: fromBase64(params.data)
|
|
1019
|
+
},
|
|
1020
|
+
protocolData
|
|
1021
|
+
);
|
|
1022
|
+
if (response.type === ILPPacketType.FULFILL) {
|
|
562
1023
|
return {
|
|
563
1024
|
accepted: true,
|
|
564
|
-
data:
|
|
1025
|
+
data: response.data.length > 0 ? toBase64(response.data) : void 0
|
|
565
1026
|
};
|
|
566
1027
|
}
|
|
567
|
-
const reject = response;
|
|
568
1028
|
return {
|
|
569
1029
|
accepted: false,
|
|
570
|
-
code:
|
|
571
|
-
message:
|
|
572
|
-
data:
|
|
1030
|
+
code: response.code,
|
|
1031
|
+
message: response.message,
|
|
1032
|
+
data: response.data.length > 0 ? toBase64(response.data) : void 0
|
|
573
1033
|
};
|
|
574
1034
|
}
|
|
575
1035
|
};
|
|
@@ -661,10 +1121,14 @@ var STATE_MAP = {
|
|
|
661
1121
|
var OnChainChannelClient = class {
|
|
662
1122
|
evmSigner;
|
|
663
1123
|
chainRpcUrls;
|
|
1124
|
+
solanaConfig;
|
|
1125
|
+
minaConfig;
|
|
664
1126
|
channelContext = /* @__PURE__ */ new Map();
|
|
665
1127
|
constructor(config) {
|
|
666
1128
|
this.evmSigner = config.evmSigner;
|
|
667
1129
|
this.chainRpcUrls = config.chainRpcUrls;
|
|
1130
|
+
this.solanaConfig = config.solanaConfig;
|
|
1131
|
+
this.minaConfig = config.minaConfig;
|
|
668
1132
|
}
|
|
669
1133
|
/**
|
|
670
1134
|
* Parse chain identifier to extract chainId.
|
|
@@ -726,6 +1190,67 @@ var OnChainChannelClient = class {
|
|
|
726
1190
|
* 4. Deposit initial funds if specified
|
|
727
1191
|
*/
|
|
728
1192
|
async openChannel(params) {
|
|
1193
|
+
const chainPrefix = params.chain.split(":")[0];
|
|
1194
|
+
if (chainPrefix === "solana") return this.openSolanaChannel(params);
|
|
1195
|
+
if (chainPrefix === "mina") return this.openMinaChannel(params);
|
|
1196
|
+
return this.openEvmChannel(params);
|
|
1197
|
+
}
|
|
1198
|
+
/**
|
|
1199
|
+
* Opens a Solana payment channel (PDA creation).
|
|
1200
|
+
*/
|
|
1201
|
+
async openSolanaChannel(params) {
|
|
1202
|
+
if (!this.solanaConfig) {
|
|
1203
|
+
throw new Error(
|
|
1204
|
+
"Solana channel config not provided \u2014 cannot open Solana channel"
|
|
1205
|
+
);
|
|
1206
|
+
}
|
|
1207
|
+
const encoder = new TextEncoder();
|
|
1208
|
+
const channelSeed = encoder.encode(
|
|
1209
|
+
`channel:${toHex(this.solanaConfig.keypair).slice(0, 32)}:${params.peerAddress}:${Date.now()}`
|
|
1210
|
+
);
|
|
1211
|
+
const channelIdBytes = new Uint8Array(
|
|
1212
|
+
await crypto.subtle.digest("SHA-256", channelSeed)
|
|
1213
|
+
);
|
|
1214
|
+
const channelId = "0x" + toHex(channelIdBytes);
|
|
1215
|
+
this.channelContext.set(channelId, {
|
|
1216
|
+
chain: params.chain,
|
|
1217
|
+
tokenNetworkAddress: this.solanaConfig.programId
|
|
1218
|
+
});
|
|
1219
|
+
return { channelId, status: "opening" };
|
|
1220
|
+
}
|
|
1221
|
+
/**
|
|
1222
|
+
* Opens a Mina payment channel (zkApp state transition).
|
|
1223
|
+
* Dynamically imports o1js to avoid bundle bloat.
|
|
1224
|
+
*/
|
|
1225
|
+
async openMinaChannel(params) {
|
|
1226
|
+
if (!this.minaConfig) {
|
|
1227
|
+
throw new Error(
|
|
1228
|
+
"Mina channel config not provided \u2014 cannot open Mina channel"
|
|
1229
|
+
);
|
|
1230
|
+
}
|
|
1231
|
+
const encoder = new TextEncoder();
|
|
1232
|
+
const channelSeed = encoder.encode(
|
|
1233
|
+
`channel:${this.minaConfig.privateKey.slice(0, 16)}:${params.peerAddress}:${Date.now()}`
|
|
1234
|
+
);
|
|
1235
|
+
const channelIdBytes = new Uint8Array(
|
|
1236
|
+
await crypto.subtle.digest("SHA-256", channelSeed)
|
|
1237
|
+
);
|
|
1238
|
+
const channelId = "0x" + toHex(channelIdBytes);
|
|
1239
|
+
this.channelContext.set(channelId, {
|
|
1240
|
+
chain: params.chain,
|
|
1241
|
+
tokenNetworkAddress: this.minaConfig.zkAppAddress
|
|
1242
|
+
});
|
|
1243
|
+
return { channelId, status: "opening" };
|
|
1244
|
+
}
|
|
1245
|
+
/**
|
|
1246
|
+
* Opens an EVM payment channel on-chain.
|
|
1247
|
+
*
|
|
1248
|
+
* 1. Approve token spend if needed
|
|
1249
|
+
* 2. Call TokenNetwork.openChannel()
|
|
1250
|
+
* 3. Extract channelId from ChannelOpened event
|
|
1251
|
+
* 4. Deposit initial funds if specified
|
|
1252
|
+
*/
|
|
1253
|
+
async openEvmChannel(params) {
|
|
729
1254
|
const {
|
|
730
1255
|
chain,
|
|
731
1256
|
tokenNetwork,
|
|
@@ -830,7 +1355,7 @@ var OnChainChannelClient = class {
|
|
|
830
1355
|
|
|
831
1356
|
// src/signing/evm-signer.ts
|
|
832
1357
|
import { privateKeyToAccount } from "viem/accounts";
|
|
833
|
-
import { toHex } from "viem";
|
|
1358
|
+
import { toHex as toHex2 } from "viem";
|
|
834
1359
|
function getBalanceProofDomain(chainId, tokenNetworkAddress) {
|
|
835
1360
|
return {
|
|
836
1361
|
name: "TokenNetwork",
|
|
@@ -849,6 +1374,7 @@ var BALANCE_PROOF_TYPES = {
|
|
|
849
1374
|
]
|
|
850
1375
|
};
|
|
851
1376
|
var EvmSigner = class {
|
|
1377
|
+
chainType = "evm";
|
|
852
1378
|
_account;
|
|
853
1379
|
/**
|
|
854
1380
|
* @param privateKey - EVM private key as hex string (with or without 0x prefix) or Uint8Array
|
|
@@ -856,7 +1382,7 @@ var EvmSigner = class {
|
|
|
856
1382
|
constructor(privateKey) {
|
|
857
1383
|
let hexKey;
|
|
858
1384
|
if (privateKey instanceof Uint8Array) {
|
|
859
|
-
hexKey =
|
|
1385
|
+
hexKey = toHex2(privateKey);
|
|
860
1386
|
} else {
|
|
861
1387
|
hexKey = privateKey.startsWith("0x") ? privateKey : `0x${privateKey}`;
|
|
862
1388
|
}
|
|
@@ -866,6 +1392,10 @@ var EvmSigner = class {
|
|
|
866
1392
|
get address() {
|
|
867
1393
|
return this._account.address;
|
|
868
1394
|
}
|
|
1395
|
+
/** ChainSigner identifier — EVM address */
|
|
1396
|
+
get signerIdentifier() {
|
|
1397
|
+
return this._account.address;
|
|
1398
|
+
}
|
|
869
1399
|
/** Viem PrivateKeyAccount — usable with walletClient for on-chain transactions */
|
|
870
1400
|
get account() {
|
|
871
1401
|
return this._account;
|
|
@@ -1003,19 +1533,125 @@ async function initializeHttpMode(config) {
|
|
|
1003
1533
|
|
|
1004
1534
|
// src/channel/ChannelManager.ts
|
|
1005
1535
|
var ChannelManager = class {
|
|
1006
|
-
evmSigner;
|
|
1007
1536
|
channels = /* @__PURE__ */ new Map();
|
|
1537
|
+
chainSigners = /* @__PURE__ */ new Map();
|
|
1538
|
+
peerChannels = /* @__PURE__ */ new Map();
|
|
1539
|
+
pendingOpens = /* @__PURE__ */ new Map();
|
|
1008
1540
|
store;
|
|
1009
|
-
|
|
1541
|
+
defaultInitialDeposit;
|
|
1542
|
+
defaultSettlementTimeout;
|
|
1543
|
+
channelClient;
|
|
1544
|
+
// Legacy: keep EvmSigner reference for backwards compatibility
|
|
1545
|
+
evmSigner;
|
|
1546
|
+
constructor(evmSigner, store, config) {
|
|
1010
1547
|
this.evmSigner = evmSigner;
|
|
1011
1548
|
this.store = store;
|
|
1549
|
+
this.defaultInitialDeposit = config?.initialDeposit ?? "100000";
|
|
1550
|
+
this.defaultSettlementTimeout = config?.settlementTimeout ?? 86400;
|
|
1551
|
+
}
|
|
1552
|
+
/**
|
|
1553
|
+
* Register a chain-specific signer.
|
|
1554
|
+
*/
|
|
1555
|
+
registerChainSigner(chainType, signer) {
|
|
1556
|
+
this.chainSigners.set(chainType, signer);
|
|
1557
|
+
}
|
|
1558
|
+
/**
|
|
1559
|
+
* Set the on-chain channel client for lazy channel opening.
|
|
1560
|
+
*/
|
|
1561
|
+
setChannelClient(client) {
|
|
1562
|
+
this.channelClient = client;
|
|
1563
|
+
}
|
|
1564
|
+
/**
|
|
1565
|
+
* Get the signer for a tracked channel's chain type.
|
|
1566
|
+
* For EVM, returns an adapter wrapping the EvmSigner.
|
|
1567
|
+
*/
|
|
1568
|
+
getSignerForChannel(channelId) {
|
|
1569
|
+
const tracking = this.channels.get(channelId);
|
|
1570
|
+
if (!tracking) {
|
|
1571
|
+
throw new Error(`Channel "${channelId}" is not being tracked.`);
|
|
1572
|
+
}
|
|
1573
|
+
const signer = this.chainSigners.get(tracking.chainType);
|
|
1574
|
+
if (signer) return signer;
|
|
1575
|
+
if (tracking.chainType === "evm" && this.evmSigner) {
|
|
1576
|
+
const evmSigner = this.evmSigner;
|
|
1577
|
+
return {
|
|
1578
|
+
chainType: "evm",
|
|
1579
|
+
signerIdentifier: evmSigner.address,
|
|
1580
|
+
async signBalanceProof(params) {
|
|
1581
|
+
if (params.metadata.chainType !== "evm")
|
|
1582
|
+
throw new Error("Expected EVM metadata");
|
|
1583
|
+
return evmSigner.signBalanceProof({
|
|
1584
|
+
channelId: params.channelId,
|
|
1585
|
+
nonce: params.nonce,
|
|
1586
|
+
transferredAmount: params.transferredAmount,
|
|
1587
|
+
lockedAmount: params.lockedAmount,
|
|
1588
|
+
locksRoot: params.locksRoot,
|
|
1589
|
+
chainId: params.metadata.chainId,
|
|
1590
|
+
tokenNetworkAddress: params.metadata.tokenNetworkAddress,
|
|
1591
|
+
tokenAddress: params.metadata.tokenAddress
|
|
1592
|
+
});
|
|
1593
|
+
},
|
|
1594
|
+
buildClaimMessage(proof, senderId) {
|
|
1595
|
+
return EvmSigner.buildClaimMessage(proof, senderId);
|
|
1596
|
+
}
|
|
1597
|
+
};
|
|
1598
|
+
}
|
|
1599
|
+
throw new Error(
|
|
1600
|
+
`No signer registered for chain type: ${tracking.chainType}`
|
|
1601
|
+
);
|
|
1602
|
+
}
|
|
1603
|
+
/**
|
|
1604
|
+
* Lazily open a channel for a peer. Idempotent — returns existing channel
|
|
1605
|
+
* if already open. Deduplicates concurrent opens for the same peer.
|
|
1606
|
+
*/
|
|
1607
|
+
async ensureChannel(peerId, negotiation) {
|
|
1608
|
+
const existing = this.peerChannels.get(peerId);
|
|
1609
|
+
if (existing) return existing;
|
|
1610
|
+
const pending = this.pendingOpens.get(peerId);
|
|
1611
|
+
if (pending) return pending;
|
|
1612
|
+
if (!this.channelClient) {
|
|
1613
|
+
throw new Error(
|
|
1614
|
+
"No channel client configured \u2014 cannot open payment channel"
|
|
1615
|
+
);
|
|
1616
|
+
}
|
|
1617
|
+
const openPromise = (async () => {
|
|
1618
|
+
try {
|
|
1619
|
+
const result = await this.channelClient.openChannel({
|
|
1620
|
+
peerId,
|
|
1621
|
+
chain: negotiation.chain,
|
|
1622
|
+
token: negotiation.tokenAddress,
|
|
1623
|
+
tokenNetwork: negotiation.tokenNetwork,
|
|
1624
|
+
peerAddress: negotiation.settlementAddress,
|
|
1625
|
+
initialDeposit: negotiation.initialDeposit ?? this.defaultInitialDeposit,
|
|
1626
|
+
settlementTimeout: negotiation.settlementTimeout ?? this.defaultSettlementTimeout
|
|
1627
|
+
});
|
|
1628
|
+
this.trackChannel(result.channelId, {
|
|
1629
|
+
chainType: negotiation.chainType,
|
|
1630
|
+
chainId: typeof negotiation.chainId === "number" ? negotiation.chainId : 0,
|
|
1631
|
+
tokenNetworkAddress: negotiation.tokenNetwork ?? "",
|
|
1632
|
+
tokenAddress: negotiation.tokenAddress
|
|
1633
|
+
});
|
|
1634
|
+
this.peerChannels.set(peerId, result.channelId);
|
|
1635
|
+
return result.channelId;
|
|
1636
|
+
} finally {
|
|
1637
|
+
this.pendingOpens.delete(peerId);
|
|
1638
|
+
}
|
|
1639
|
+
})();
|
|
1640
|
+
this.pendingOpens.set(peerId, openPromise);
|
|
1641
|
+
return openPromise;
|
|
1642
|
+
}
|
|
1643
|
+
/**
|
|
1644
|
+
* Get channel ID for a peer (if any).
|
|
1645
|
+
*/
|
|
1646
|
+
getChannelForPeer(peerId) {
|
|
1647
|
+
return this.peerChannels.get(peerId);
|
|
1012
1648
|
}
|
|
1013
1649
|
/**
|
|
1014
1650
|
* Start tracking a channel.
|
|
1015
1651
|
* Called after bootstrap returns a channelId.
|
|
1016
1652
|
*
|
|
1017
1653
|
* @param channelId - Payment channel identifier
|
|
1018
|
-
* @param chainContext - Chain context for
|
|
1654
|
+
* @param chainContext - Chain context for signing (chainType + chainId + tokenNetworkAddress)
|
|
1019
1655
|
* @param initialNonce - Starting nonce (default: 0)
|
|
1020
1656
|
* @param initialAmount - Starting cumulative amount (default: 0n)
|
|
1021
1657
|
*/
|
|
@@ -1028,6 +1664,7 @@ var ChannelManager = class {
|
|
|
1028
1664
|
this.channels.set(channelId, {
|
|
1029
1665
|
nonce: persisted.nonce,
|
|
1030
1666
|
cumulativeAmount: persisted.cumulativeAmount,
|
|
1667
|
+
chainType: chainContext?.chainType ?? "evm",
|
|
1031
1668
|
chainId: cId,
|
|
1032
1669
|
tokenNetworkAddress: tnAddr,
|
|
1033
1670
|
tokenAddress: chainContext?.tokenAddress
|
|
@@ -1038,6 +1675,7 @@ var ChannelManager = class {
|
|
|
1038
1675
|
this.channels.set(channelId, {
|
|
1039
1676
|
nonce: initialNonce,
|
|
1040
1677
|
cumulativeAmount: initialAmount,
|
|
1678
|
+
chainType: chainContext?.chainType ?? "evm",
|
|
1041
1679
|
chainId: cId,
|
|
1042
1680
|
tokenNetworkAddress: tnAddr,
|
|
1043
1681
|
tokenAddress: chainContext?.tokenAddress
|
|
@@ -1046,6 +1684,7 @@ var ChannelManager = class {
|
|
|
1046
1684
|
/**
|
|
1047
1685
|
* Signs a balance proof for the given channel.
|
|
1048
1686
|
* Auto-increments nonce and adds to cumulative amount.
|
|
1687
|
+
* Routes to the correct ChainSigner based on the channel's chain type.
|
|
1049
1688
|
*
|
|
1050
1689
|
* @param channelId - Payment channel identifier
|
|
1051
1690
|
* @param additionalAmount - Amount to add to cumulative transferred amount
|
|
@@ -1067,6 +1706,21 @@ var ChannelManager = class {
|
|
|
1067
1706
|
cumulativeAmount: tracking.cumulativeAmount
|
|
1068
1707
|
});
|
|
1069
1708
|
}
|
|
1709
|
+
const signer = this.chainSigners.get(tracking.chainType);
|
|
1710
|
+
if (signer && tracking.chainType !== "evm") {
|
|
1711
|
+
const metadata = this.buildMetadata(tracking);
|
|
1712
|
+
return signer.signBalanceProof({
|
|
1713
|
+
channelId,
|
|
1714
|
+
nonce: tracking.nonce,
|
|
1715
|
+
transferredAmount: tracking.cumulativeAmount,
|
|
1716
|
+
lockedAmount: 0n,
|
|
1717
|
+
locksRoot: "0x0000000000000000000000000000000000000000000000000000000000000000",
|
|
1718
|
+
metadata
|
|
1719
|
+
});
|
|
1720
|
+
}
|
|
1721
|
+
if (!this.evmSigner) {
|
|
1722
|
+
throw new Error("No EVM signer configured for EVM channel signing.");
|
|
1723
|
+
}
|
|
1070
1724
|
return this.evmSigner.signBalanceProof({
|
|
1071
1725
|
channelId,
|
|
1072
1726
|
nonce: tracking.nonce,
|
|
@@ -1078,6 +1732,24 @@ var ChannelManager = class {
|
|
|
1078
1732
|
tokenAddress: tracking.tokenAddress
|
|
1079
1733
|
});
|
|
1080
1734
|
}
|
|
1735
|
+
buildMetadata(tracking) {
|
|
1736
|
+
switch (tracking.chainType) {
|
|
1737
|
+
case "solana":
|
|
1738
|
+
return { chainType: "solana", programId: tracking.tokenNetworkAddress };
|
|
1739
|
+
case "mina":
|
|
1740
|
+
return {
|
|
1741
|
+
chainType: "mina",
|
|
1742
|
+
zkAppAddress: tracking.tokenNetworkAddress
|
|
1743
|
+
};
|
|
1744
|
+
default:
|
|
1745
|
+
return {
|
|
1746
|
+
chainType: "evm",
|
|
1747
|
+
chainId: tracking.chainId,
|
|
1748
|
+
tokenNetworkAddress: tracking.tokenNetworkAddress,
|
|
1749
|
+
tokenAddress: tracking.tokenAddress
|
|
1750
|
+
};
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1081
1753
|
/**
|
|
1082
1754
|
* Gets the current nonce for a tracked channel.
|
|
1083
1755
|
*/
|
|
@@ -1162,6 +1834,7 @@ var ToonClient = class {
|
|
|
1162
1834
|
state = null;
|
|
1163
1835
|
evmSigner;
|
|
1164
1836
|
channelManager;
|
|
1837
|
+
peerNegotiations = /* @__PURE__ */ new Map();
|
|
1165
1838
|
/**
|
|
1166
1839
|
* Creates a new ToonClient instance.
|
|
1167
1840
|
*
|
|
@@ -1236,13 +1909,50 @@ var ToonClient = class {
|
|
|
1236
1909
|
);
|
|
1237
1910
|
}
|
|
1238
1911
|
const bootstrapResults = await bootstrapService.bootstrap();
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1912
|
+
for (const result of bootstrapResults) {
|
|
1913
|
+
if (result.negotiatedChain && result.settlementAddress) {
|
|
1914
|
+
const chainType = result.negotiatedChain.split(":")[0] ?? "evm";
|
|
1915
|
+
const parts = result.negotiatedChain.split(":");
|
|
1916
|
+
const chainId = parts.length >= 3 ? parseInt(parts[2] ?? "0", 10) : 0;
|
|
1917
|
+
const r = result;
|
|
1918
|
+
this.peerNegotiations.set(result.registeredPeerId, {
|
|
1919
|
+
chain: result.negotiatedChain,
|
|
1920
|
+
chainType,
|
|
1921
|
+
chainId: isNaN(chainId) ? 0 : chainId,
|
|
1922
|
+
settlementAddress: result.settlementAddress,
|
|
1923
|
+
tokenAddress: r.tokenAddress,
|
|
1924
|
+
tokenNetwork: r.tokenNetwork
|
|
1925
|
+
});
|
|
1926
|
+
} else if (result.registeredPeerId && !this.peerNegotiations.has(result.registeredPeerId)) {
|
|
1927
|
+
const peerInfo = result.peerInfo;
|
|
1928
|
+
const peerChains = peerInfo.supportedChains ?? [];
|
|
1929
|
+
const ourChains = this.config.supportedChains ?? [];
|
|
1930
|
+
const matchedChain = ourChains.find((c) => peerChains.includes(c)) ?? ourChains[0];
|
|
1931
|
+
if (matchedChain) {
|
|
1932
|
+
const peerAddr = peerInfo.settlementAddresses?.[matchedChain];
|
|
1933
|
+
const parts = matchedChain.split(":");
|
|
1934
|
+
const chainId = parts.length >= 3 ? parseInt(parts[2] ?? "0", 10) : 0;
|
|
1935
|
+
if (peerAddr) {
|
|
1936
|
+
this.peerNegotiations.set(result.registeredPeerId, {
|
|
1937
|
+
chain: matchedChain,
|
|
1938
|
+
chainType: parts[0] ?? "evm",
|
|
1939
|
+
chainId: isNaN(chainId) ? 0 : chainId,
|
|
1940
|
+
settlementAddress: peerAddr,
|
|
1941
|
+
tokenAddress: peerInfo.preferredTokens?.[matchedChain] ?? this.config.preferredTokens?.[matchedChain],
|
|
1942
|
+
tokenNetwork: peerInfo.tokenNetworks?.[matchedChain] ?? this.config.tokenNetworks?.[matchedChain]
|
|
1943
|
+
});
|
|
1944
|
+
}
|
|
1244
1945
|
}
|
|
1245
1946
|
}
|
|
1947
|
+
if (this.channelManager && result.channelId && !this.channelManager.isTracking(result.channelId)) {
|
|
1948
|
+
const chainCtx = this.getChainContext(result.negotiatedChain);
|
|
1949
|
+
this.channelManager.trackChannel(result.channelId, chainCtx);
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
if (this.channelManager && initialization.onChainChannelClient) {
|
|
1953
|
+
this.channelManager.setChannelClient(
|
|
1954
|
+
initialization.onChainChannelClient
|
|
1955
|
+
);
|
|
1246
1956
|
}
|
|
1247
1957
|
this.state = {
|
|
1248
1958
|
bootstrapService,
|
|
@@ -1286,27 +1996,50 @@ var ToonClient = class {
|
|
|
1286
1996
|
const basePricePerByte = 10n;
|
|
1287
1997
|
const amount = String(BigInt(toonData.length) * basePricePerByte);
|
|
1288
1998
|
const destination = options?.destination ?? this.config.destinationAddress;
|
|
1289
|
-
if (!options?.claim) {
|
|
1290
|
-
throw new ToonClientError(
|
|
1291
|
-
"Signed balance proof required. Call signBalanceProof() first.",
|
|
1292
|
-
"MISSING_CLAIM"
|
|
1293
|
-
);
|
|
1294
|
-
}
|
|
1295
1999
|
if (!this.state.btpClient) {
|
|
1296
2000
|
throw new ToonClientError(
|
|
1297
2001
|
"BTP client required for publishing. Configure btpUrl.",
|
|
1298
2002
|
"NO_BTP_CLIENT"
|
|
1299
2003
|
);
|
|
1300
2004
|
}
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
2005
|
+
let claimMessage;
|
|
2006
|
+
if (options?.claim) {
|
|
2007
|
+
claimMessage = EvmSigner.buildClaimMessage(
|
|
2008
|
+
options.claim,
|
|
2009
|
+
this.getPublicKey()
|
|
2010
|
+
);
|
|
2011
|
+
} else if (this.channelManager) {
|
|
2012
|
+
const peerId = this.resolvePeerId(destination);
|
|
2013
|
+
const negotiation = this.peerNegotiations.get(peerId);
|
|
2014
|
+
if (!negotiation) {
|
|
2015
|
+
throw new ToonClientError(
|
|
2016
|
+
`No negotiation metadata for peer "${peerId}" \u2014 was bootstrap completed?`,
|
|
2017
|
+
"PEER_NOT_NEGOTIATED"
|
|
2018
|
+
);
|
|
2019
|
+
}
|
|
2020
|
+
const channelId = await this.channelManager.ensureChannel(
|
|
2021
|
+
peerId,
|
|
2022
|
+
negotiation
|
|
2023
|
+
);
|
|
2024
|
+
const proof = await this.channelManager.signBalanceProof(
|
|
2025
|
+
channelId,
|
|
2026
|
+
BigInt(amount)
|
|
2027
|
+
);
|
|
2028
|
+
const signer = this.channelManager.getSignerForChannel(channelId);
|
|
2029
|
+
claimMessage = signer.buildClaimMessage(proof, this.getPublicKey());
|
|
2030
|
+
} else {
|
|
2031
|
+
throw new ToonClientError(
|
|
2032
|
+
"No claim provided and no channel manager configured",
|
|
2033
|
+
"MISSING_CLAIM"
|
|
2034
|
+
);
|
|
2035
|
+
}
|
|
1305
2036
|
const response = await this.state.btpClient.sendIlpPacketWithClaim(
|
|
1306
2037
|
{
|
|
1307
2038
|
destination,
|
|
1308
2039
|
amount,
|
|
1309
|
-
data:
|
|
2040
|
+
data: toBase64(
|
|
2041
|
+
toonData instanceof Uint8Array ? toonData : new Uint8Array(toonData)
|
|
2042
|
+
)
|
|
1310
2043
|
},
|
|
1311
2044
|
claimMessage
|
|
1312
2045
|
);
|
|
@@ -1322,6 +2055,11 @@ var ToonClient = class {
|
|
|
1322
2055
|
data: response.data
|
|
1323
2056
|
};
|
|
1324
2057
|
} catch (error) {
|
|
2058
|
+
console.error(
|
|
2059
|
+
"[ToonClient.publishEvent] ROOT CAUSE:",
|
|
2060
|
+
String(error),
|
|
2061
|
+
error instanceof Error ? error.stack : ""
|
|
2062
|
+
);
|
|
1325
2063
|
throw new ToonClientError(
|
|
1326
2064
|
"Failed to publish event",
|
|
1327
2065
|
"PUBLISH_ERROR",
|
|
@@ -1367,6 +2105,30 @@ var ToonClient = class {
|
|
|
1367
2105
|
if (!this.channelManager) throw new Error("ChannelManager not initialized");
|
|
1368
2106
|
return this.channelManager.getCumulativeAmount(channelId);
|
|
1369
2107
|
}
|
|
2108
|
+
/**
|
|
2109
|
+
* Resolves an ILP destination address to a peer ID.
|
|
2110
|
+
* Convention: destination "g.toon.peer1" → peerId "peer1" (last segment).
|
|
2111
|
+
* Falls back to first known peer if no match.
|
|
2112
|
+
*/
|
|
2113
|
+
resolvePeerId(destination) {
|
|
2114
|
+
const segments = destination.split(".");
|
|
2115
|
+
const lastSegment = segments[segments.length - 1] ?? "";
|
|
2116
|
+
if (lastSegment && this.peerNegotiations.has(lastSegment)) {
|
|
2117
|
+
return lastSegment;
|
|
2118
|
+
}
|
|
2119
|
+
for (const peerId of this.peerNegotiations.keys()) {
|
|
2120
|
+
if (destination.endsWith(`.${peerId}`) || destination.endsWith(`.${peerId.replace("nostr-", "")}`)) {
|
|
2121
|
+
return peerId;
|
|
2122
|
+
}
|
|
2123
|
+
}
|
|
2124
|
+
const firstPeerResult = this.peerNegotiations.keys().next();
|
|
2125
|
+
if (!firstPeerResult.done && firstPeerResult.value)
|
|
2126
|
+
return firstPeerResult.value;
|
|
2127
|
+
throw new ToonClientError(
|
|
2128
|
+
`Cannot resolve peer for destination: ${destination}`,
|
|
2129
|
+
"PEER_NOT_FOUND"
|
|
2130
|
+
);
|
|
2131
|
+
}
|
|
1370
2132
|
/**
|
|
1371
2133
|
* Extracts chain context (chainId + tokenNetworkAddress) from a chain key like 'evm:base:421614'.
|
|
1372
2134
|
*/
|
|
@@ -1424,7 +2186,10 @@ var ToonClient = class {
|
|
|
1424
2186
|
params.claim,
|
|
1425
2187
|
this.getPublicKey()
|
|
1426
2188
|
);
|
|
1427
|
-
return this.state.btpClient.sendIlpPacketWithClaim(
|
|
2189
|
+
return this.state.btpClient.sendIlpPacketWithClaim(
|
|
2190
|
+
ilpParams,
|
|
2191
|
+
claimMessage
|
|
2192
|
+
);
|
|
1428
2193
|
}
|
|
1429
2194
|
/**
|
|
1430
2195
|
* Stops the ToonClient and cleans up resources.
|
|
@@ -1798,6 +2563,1212 @@ var HttpConnectorAdmin = class {
|
|
|
1798
2563
|
}
|
|
1799
2564
|
}
|
|
1800
2565
|
};
|
|
2566
|
+
|
|
2567
|
+
// src/signing/solana-signer.ts
|
|
2568
|
+
var BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
|
2569
|
+
function toBase58(bytes) {
|
|
2570
|
+
let num = BigInt(0);
|
|
2571
|
+
for (const b of bytes) num = num * 256n + BigInt(b);
|
|
2572
|
+
let result = "";
|
|
2573
|
+
while (num > 0n) {
|
|
2574
|
+
result = BASE58_ALPHABET[Number(num % 58n)] + result;
|
|
2575
|
+
num = num / 58n;
|
|
2576
|
+
}
|
|
2577
|
+
for (const b of bytes) {
|
|
2578
|
+
if (b === 0) result = "1" + result;
|
|
2579
|
+
else break;
|
|
2580
|
+
}
|
|
2581
|
+
return result;
|
|
2582
|
+
}
|
|
2583
|
+
var _ed25519 = null;
|
|
2584
|
+
async function getEd25519() {
|
|
2585
|
+
if (!_ed25519) {
|
|
2586
|
+
const mod = await import("@noble/curves/ed25519");
|
|
2587
|
+
_ed25519 = mod.ed25519;
|
|
2588
|
+
}
|
|
2589
|
+
return _ed25519;
|
|
2590
|
+
}
|
|
2591
|
+
var SolanaSigner = class {
|
|
2592
|
+
chainType = "solana";
|
|
2593
|
+
privateKey;
|
|
2594
|
+
publicKey;
|
|
2595
|
+
pubkeyBase58Cache;
|
|
2596
|
+
constructor(privateKey) {
|
|
2597
|
+
this.privateKey = privateKey;
|
|
2598
|
+
}
|
|
2599
|
+
async ensurePublicKey() {
|
|
2600
|
+
if (this.publicKey && this.pubkeyBase58Cache) {
|
|
2601
|
+
return { publicKey: this.publicKey, base58: this.pubkeyBase58Cache };
|
|
2602
|
+
}
|
|
2603
|
+
const ed = await getEd25519();
|
|
2604
|
+
const pk = ed.getPublicKey(this.privateKey);
|
|
2605
|
+
const b58 = toBase58(pk);
|
|
2606
|
+
this.publicKey = pk;
|
|
2607
|
+
this.pubkeyBase58Cache = b58;
|
|
2608
|
+
return { publicKey: pk, base58: b58 };
|
|
2609
|
+
}
|
|
2610
|
+
get signerIdentifier() {
|
|
2611
|
+
return this.pubkeyBase58Cache ?? "uninitialized";
|
|
2612
|
+
}
|
|
2613
|
+
async signBalanceProof(params) {
|
|
2614
|
+
if (params.metadata.chainType !== "solana") {
|
|
2615
|
+
throw new Error(
|
|
2616
|
+
`SolanaSigner cannot sign for chain type: ${params.metadata.chainType}`
|
|
2617
|
+
);
|
|
2618
|
+
}
|
|
2619
|
+
const ed = await getEd25519();
|
|
2620
|
+
const { base58 } = await this.ensurePublicKey();
|
|
2621
|
+
const encoder = new TextEncoder();
|
|
2622
|
+
const message = encoder.encode(
|
|
2623
|
+
`${params.channelId}:${params.nonce}:${params.transferredAmount}:${params.lockedAmount}:${params.locksRoot}`
|
|
2624
|
+
);
|
|
2625
|
+
const signature = ed.sign(message, this.privateKey);
|
|
2626
|
+
const signatureHex = "0x" + toHex(new Uint8Array(signature));
|
|
2627
|
+
return {
|
|
2628
|
+
channelId: params.channelId,
|
|
2629
|
+
nonce: params.nonce,
|
|
2630
|
+
transferredAmount: params.transferredAmount,
|
|
2631
|
+
lockedAmount: params.lockedAmount,
|
|
2632
|
+
locksRoot: params.locksRoot,
|
|
2633
|
+
signature: signatureHex,
|
|
2634
|
+
signerAddress: base58,
|
|
2635
|
+
chainId: 0,
|
|
2636
|
+
tokenNetworkAddress: params.metadata.programId
|
|
2637
|
+
};
|
|
2638
|
+
}
|
|
2639
|
+
buildClaimMessage(proof, senderId) {
|
|
2640
|
+
const claim = {
|
|
2641
|
+
version: "1.0",
|
|
2642
|
+
blockchain: "solana",
|
|
2643
|
+
messageId: crypto.randomUUID(),
|
|
2644
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString().replace(/\.\d{3}Z$/, ".000Z"),
|
|
2645
|
+
senderId,
|
|
2646
|
+
channelId: proof.channelId,
|
|
2647
|
+
nonce: proof.nonce,
|
|
2648
|
+
transferredAmount: proof.transferredAmount.toString(),
|
|
2649
|
+
signature: proof.signature,
|
|
2650
|
+
signerAddress: this.pubkeyBase58Cache ?? proof.signerAddress,
|
|
2651
|
+
programId: proof.tokenNetworkAddress
|
|
2652
|
+
};
|
|
2653
|
+
return claim;
|
|
2654
|
+
}
|
|
2655
|
+
};
|
|
2656
|
+
|
|
2657
|
+
// src/signing/mina-signer.ts
|
|
2658
|
+
var MinaSigner = class {
|
|
2659
|
+
chainType = "mina";
|
|
2660
|
+
privateKeyBase58;
|
|
2661
|
+
publicKeyBase58 = "uninitialized";
|
|
2662
|
+
constructor(privateKeyBase58) {
|
|
2663
|
+
this.privateKeyBase58 = privateKeyBase58;
|
|
2664
|
+
}
|
|
2665
|
+
get signerIdentifier() {
|
|
2666
|
+
return this.publicKeyBase58;
|
|
2667
|
+
}
|
|
2668
|
+
async ensurePublicKey() {
|
|
2669
|
+
if (this.publicKeyBase58 !== "uninitialized") return this.publicKeyBase58;
|
|
2670
|
+
const o1js = await import("o1js");
|
|
2671
|
+
const pk = o1js.PrivateKey.fromBase58(this.privateKeyBase58);
|
|
2672
|
+
this.publicKeyBase58 = pk.toPublicKey().toBase58();
|
|
2673
|
+
return this.publicKeyBase58;
|
|
2674
|
+
}
|
|
2675
|
+
async signBalanceProof(params) {
|
|
2676
|
+
if (params.metadata.chainType !== "mina") {
|
|
2677
|
+
throw new Error(
|
|
2678
|
+
`MinaSigner cannot sign for chain type: ${params.metadata.chainType}`
|
|
2679
|
+
);
|
|
2680
|
+
}
|
|
2681
|
+
const o1js = await import("o1js");
|
|
2682
|
+
const pubkey = await this.ensurePublicKey();
|
|
2683
|
+
const channelIdNum = BigInt(
|
|
2684
|
+
"0x" + params.channelId.replace(/^0x/, "").slice(0, 16)
|
|
2685
|
+
);
|
|
2686
|
+
const commitment = o1js.Poseidon.hash([
|
|
2687
|
+
o1js.Field(channelIdNum),
|
|
2688
|
+
o1js.Field(params.nonce),
|
|
2689
|
+
o1js.Field(params.transferredAmount),
|
|
2690
|
+
o1js.Field(params.lockedAmount)
|
|
2691
|
+
]);
|
|
2692
|
+
const pk = o1js.PrivateKey.fromBase58(this.privateKeyBase58);
|
|
2693
|
+
const signature = o1js.Signature.create(pk, [commitment]);
|
|
2694
|
+
return {
|
|
2695
|
+
channelId: params.channelId,
|
|
2696
|
+
nonce: params.nonce,
|
|
2697
|
+
transferredAmount: params.transferredAmount,
|
|
2698
|
+
lockedAmount: params.lockedAmount,
|
|
2699
|
+
locksRoot: params.locksRoot,
|
|
2700
|
+
signature: signature.toBase58(),
|
|
2701
|
+
signerAddress: pubkey,
|
|
2702
|
+
chainId: 0,
|
|
2703
|
+
tokenNetworkAddress: params.metadata.zkAppAddress
|
|
2704
|
+
};
|
|
2705
|
+
}
|
|
2706
|
+
buildClaimMessage(proof, senderId) {
|
|
2707
|
+
const claim = {
|
|
2708
|
+
version: "1.0",
|
|
2709
|
+
blockchain: "mina",
|
|
2710
|
+
messageId: crypto.randomUUID(),
|
|
2711
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString().replace(/\.\d{3}Z$/, ".000Z"),
|
|
2712
|
+
senderId,
|
|
2713
|
+
channelId: proof.channelId,
|
|
2714
|
+
nonce: proof.nonce,
|
|
2715
|
+
transferredAmount: proof.transferredAmount.toString(),
|
|
2716
|
+
commitment: proof.signature,
|
|
2717
|
+
signerAddress: proof.signerAddress,
|
|
2718
|
+
zkAppAddress: proof.tokenNetworkAddress
|
|
2719
|
+
};
|
|
2720
|
+
return claim;
|
|
2721
|
+
}
|
|
2722
|
+
};
|
|
2723
|
+
|
|
2724
|
+
// src/keys/KeyManager.ts
|
|
2725
|
+
import { finalizeEvent } from "nostr-tools/pure";
|
|
2726
|
+
import { nip19 } from "nostr-tools";
|
|
2727
|
+
|
|
2728
|
+
// src/keys/KeyDerivation.ts
|
|
2729
|
+
import { generateSecretKey as generateSecretKey3, getPublicKey as getPublicKey2 } from "nostr-tools/pure";
|
|
2730
|
+
import { privateKeyToAccount as privateKeyToAccount2 } from "viem/accounts";
|
|
2731
|
+
import { toHex as toHex3 } from "viem";
|
|
2732
|
+
import {
|
|
2733
|
+
generateMnemonic as _genMnemonic,
|
|
2734
|
+
validateMnemonic as _validateMnemonic,
|
|
2735
|
+
mnemonicToSeedSync
|
|
2736
|
+
} from "@scure/bip39";
|
|
2737
|
+
import { wordlist as english } from "@scure/bip39/wordlists/english";
|
|
2738
|
+
import { HDKey } from "@scure/bip32";
|
|
2739
|
+
function generateMnemonic() {
|
|
2740
|
+
return _genMnemonic(english, 128);
|
|
2741
|
+
}
|
|
2742
|
+
function validateMnemonic(mnemonic) {
|
|
2743
|
+
return _validateMnemonic(mnemonic, english);
|
|
2744
|
+
}
|
|
2745
|
+
function deriveNostrKey(seed) {
|
|
2746
|
+
const master = HDKey.fromMasterSeed(seed);
|
|
2747
|
+
const child = master.derive("m/44'/1237'/0'/0/0");
|
|
2748
|
+
if (!child.privateKey) {
|
|
2749
|
+
throw new Error("Failed to derive Nostr private key from seed");
|
|
2750
|
+
}
|
|
2751
|
+
const secretKey = new Uint8Array(child.privateKey);
|
|
2752
|
+
const pubkey = getPublicKey2(secretKey);
|
|
2753
|
+
return { secretKey, pubkey };
|
|
2754
|
+
}
|
|
2755
|
+
function deriveEvmIdentity(secretKey) {
|
|
2756
|
+
const account = privateKeyToAccount2(toHex3(secretKey));
|
|
2757
|
+
return {
|
|
2758
|
+
privateKey: secretKey,
|
|
2759
|
+
address: account.address
|
|
2760
|
+
};
|
|
2761
|
+
}
|
|
2762
|
+
async function deriveSolanaKey(seed) {
|
|
2763
|
+
const { hmac } = await import("@noble/hashes/hmac");
|
|
2764
|
+
const { sha512 } = await import("@noble/hashes/sha512");
|
|
2765
|
+
const { ed25519 } = await import("@noble/curves/ed25519");
|
|
2766
|
+
const encoder = new TextEncoder();
|
|
2767
|
+
let I = hmac(sha512, encoder.encode("ed25519 seed"), seed);
|
|
2768
|
+
let key = I.slice(0, 32);
|
|
2769
|
+
let chainCode = I.slice(32);
|
|
2770
|
+
const indices = [
|
|
2771
|
+
2147483692,
|
|
2772
|
+
// 44'
|
|
2773
|
+
2147484149,
|
|
2774
|
+
// 501'
|
|
2775
|
+
2147483648,
|
|
2776
|
+
// 0'
|
|
2777
|
+
2147483648
|
|
2778
|
+
// 0'
|
|
2779
|
+
];
|
|
2780
|
+
for (const index of indices) {
|
|
2781
|
+
const data = new Uint8Array(37);
|
|
2782
|
+
data[0] = 0;
|
|
2783
|
+
data.set(key, 1);
|
|
2784
|
+
data[33] = index >>> 24 & 255;
|
|
2785
|
+
data[34] = index >>> 16 & 255;
|
|
2786
|
+
data[35] = index >>> 8 & 255;
|
|
2787
|
+
data[36] = index & 255;
|
|
2788
|
+
I = hmac(sha512, chainCode, data);
|
|
2789
|
+
key = I.slice(0, 32);
|
|
2790
|
+
chainCode = I.slice(32);
|
|
2791
|
+
}
|
|
2792
|
+
const publicKeyBytes = ed25519.getPublicKey(key);
|
|
2793
|
+
const keypair = new Uint8Array(64);
|
|
2794
|
+
keypair.set(key, 0);
|
|
2795
|
+
keypair.set(publicKeyBytes, 32);
|
|
2796
|
+
const publicKey = toBase582(publicKeyBytes);
|
|
2797
|
+
return { secretKey: keypair, publicKey };
|
|
2798
|
+
}
|
|
2799
|
+
async function deriveMinaKey(seed) {
|
|
2800
|
+
const master = HDKey.fromMasterSeed(seed);
|
|
2801
|
+
const child = master.derive("m/44'/12586'/0'/0/0");
|
|
2802
|
+
if (!child.privateKey) {
|
|
2803
|
+
throw new Error("Failed to derive Mina private key from seed");
|
|
2804
|
+
}
|
|
2805
|
+
const keyBytes = new Uint8Array(child.privateKey);
|
|
2806
|
+
try {
|
|
2807
|
+
const MinaSignerLib = await import("./mina-signer-J7GFWOGO.js");
|
|
2808
|
+
const Client = "default" in MinaSignerLib ? MinaSignerLib.default : MinaSignerLib;
|
|
2809
|
+
const client = new Client({ network: "mainnet" });
|
|
2810
|
+
const hexKey = Array.from(keyBytes).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
2811
|
+
const keypair = client.derivePublicKey(hexKey);
|
|
2812
|
+
return {
|
|
2813
|
+
privateKey: hexKey,
|
|
2814
|
+
publicKey: keypair
|
|
2815
|
+
};
|
|
2816
|
+
} catch {
|
|
2817
|
+
throw new Error(
|
|
2818
|
+
"mina-signer is required for Mina key derivation. Install it as an optional dependency."
|
|
2819
|
+
);
|
|
2820
|
+
}
|
|
2821
|
+
}
|
|
2822
|
+
async function deriveFullIdentity(mnemonic) {
|
|
2823
|
+
const seed = mnemonicToSeedSync(mnemonic);
|
|
2824
|
+
const nostr = deriveNostrKey(seed);
|
|
2825
|
+
const evm = deriveEvmIdentity(nostr.secretKey);
|
|
2826
|
+
let solana;
|
|
2827
|
+
try {
|
|
2828
|
+
solana = await deriveSolanaKey(seed);
|
|
2829
|
+
} catch {
|
|
2830
|
+
solana = { secretKey: new Uint8Array(64), publicKey: "" };
|
|
2831
|
+
}
|
|
2832
|
+
let mina;
|
|
2833
|
+
try {
|
|
2834
|
+
mina = await deriveMinaKey(seed);
|
|
2835
|
+
} catch {
|
|
2836
|
+
mina = { privateKey: "", publicKey: "" };
|
|
2837
|
+
}
|
|
2838
|
+
seed.fill(0);
|
|
2839
|
+
return { nostr, evm, solana, mina };
|
|
2840
|
+
}
|
|
2841
|
+
function deriveFromNsec(secretKey) {
|
|
2842
|
+
const keyCopy = new Uint8Array(secretKey);
|
|
2843
|
+
const pubkey = getPublicKey2(keyCopy);
|
|
2844
|
+
const evm = deriveEvmIdentity(keyCopy);
|
|
2845
|
+
return {
|
|
2846
|
+
nostr: { secretKey: keyCopy, pubkey },
|
|
2847
|
+
evm,
|
|
2848
|
+
solana: { secretKey: new Uint8Array(64), publicKey: "" },
|
|
2849
|
+
mina: { privateKey: "", publicKey: "" }
|
|
2850
|
+
};
|
|
2851
|
+
}
|
|
2852
|
+
function generateRandomIdentity() {
|
|
2853
|
+
const secretKey = generateSecretKey3();
|
|
2854
|
+
return deriveFromNsec(secretKey);
|
|
2855
|
+
}
|
|
2856
|
+
var BASE58_ALPHABET2 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
|
2857
|
+
function toBase582(bytes) {
|
|
2858
|
+
let num = BigInt(0);
|
|
2859
|
+
for (const b of bytes) num = num * 256n + BigInt(b);
|
|
2860
|
+
let result = "";
|
|
2861
|
+
while (num > 0n) {
|
|
2862
|
+
result = BASE58_ALPHABET2[Number(num % 58n)] + result;
|
|
2863
|
+
num = num / 58n;
|
|
2864
|
+
}
|
|
2865
|
+
for (const b of bytes) {
|
|
2866
|
+
if (b === 0) result = "1" + result;
|
|
2867
|
+
else break;
|
|
2868
|
+
}
|
|
2869
|
+
return result;
|
|
2870
|
+
}
|
|
2871
|
+
|
|
2872
|
+
// src/keys/PasskeyAuth.ts
|
|
2873
|
+
async function registerPasskey(params) {
|
|
2874
|
+
const { rpId, rpName, userId, userName, prfSalt } = params;
|
|
2875
|
+
const publicKeyOptions = {
|
|
2876
|
+
rp: { id: rpId, name: rpName },
|
|
2877
|
+
user: {
|
|
2878
|
+
id: userId,
|
|
2879
|
+
name: userName,
|
|
2880
|
+
displayName: userName
|
|
2881
|
+
},
|
|
2882
|
+
challenge: crypto.getRandomValues(
|
|
2883
|
+
new Uint8Array(32)
|
|
2884
|
+
),
|
|
2885
|
+
pubKeyCredParams: [
|
|
2886
|
+
{ alg: -7, type: "public-key" },
|
|
2887
|
+
// ES256
|
|
2888
|
+
{ alg: -257, type: "public-key" }
|
|
2889
|
+
// RS256
|
|
2890
|
+
],
|
|
2891
|
+
authenticatorSelection: {
|
|
2892
|
+
residentKey: "required",
|
|
2893
|
+
userVerification: "required"
|
|
2894
|
+
},
|
|
2895
|
+
extensions: {
|
|
2896
|
+
prf: {
|
|
2897
|
+
eval: {
|
|
2898
|
+
first: prfSalt
|
|
2899
|
+
}
|
|
2900
|
+
}
|
|
2901
|
+
}
|
|
2902
|
+
};
|
|
2903
|
+
const credential = await navigator.credentials.create({
|
|
2904
|
+
publicKey: publicKeyOptions
|
|
2905
|
+
});
|
|
2906
|
+
if (!credential) {
|
|
2907
|
+
throw new Error("Passkey registration was cancelled or failed");
|
|
2908
|
+
}
|
|
2909
|
+
const response = credential.response;
|
|
2910
|
+
const extensionResults = credential.getClientExtensionResults();
|
|
2911
|
+
const prfResults = extensionResults["prf"];
|
|
2912
|
+
if (!prfResults?.results?.first) {
|
|
2913
|
+
throw new Error(
|
|
2914
|
+
"PRF extension not supported by this authenticator. Passkey was created but cannot be used for key encryption. Use password-based encryption as fallback."
|
|
2915
|
+
);
|
|
2916
|
+
}
|
|
2917
|
+
const credentialId = new Uint8Array(credential.rawId);
|
|
2918
|
+
if (!response.attestationObject) {
|
|
2919
|
+
throw new Error("Invalid attestation response");
|
|
2920
|
+
}
|
|
2921
|
+
return {
|
|
2922
|
+
prfOutput: prfResults.results.first,
|
|
2923
|
+
credentialId
|
|
2924
|
+
};
|
|
2925
|
+
}
|
|
2926
|
+
async function assertPasskey(params) {
|
|
2927
|
+
const { rpId, prfSalt, allowCredentials } = params;
|
|
2928
|
+
const publicKeyOptions = {
|
|
2929
|
+
rpId,
|
|
2930
|
+
challenge: crypto.getRandomValues(
|
|
2931
|
+
new Uint8Array(32)
|
|
2932
|
+
),
|
|
2933
|
+
userVerification: "required",
|
|
2934
|
+
...allowCredentials && {
|
|
2935
|
+
allowCredentials: allowCredentials.map((id) => ({
|
|
2936
|
+
id,
|
|
2937
|
+
type: "public-key"
|
|
2938
|
+
}))
|
|
2939
|
+
},
|
|
2940
|
+
extensions: {
|
|
2941
|
+
prf: {
|
|
2942
|
+
eval: {
|
|
2943
|
+
first: prfSalt
|
|
2944
|
+
}
|
|
2945
|
+
}
|
|
2946
|
+
}
|
|
2947
|
+
};
|
|
2948
|
+
const credential = await navigator.credentials.get({
|
|
2949
|
+
publicKey: publicKeyOptions
|
|
2950
|
+
});
|
|
2951
|
+
if (!credential) {
|
|
2952
|
+
throw new Error("Passkey assertion was cancelled or failed");
|
|
2953
|
+
}
|
|
2954
|
+
const response = credential.response;
|
|
2955
|
+
const extensionResults = credential.getClientExtensionResults();
|
|
2956
|
+
const prfResults = extensionResults["prf"];
|
|
2957
|
+
if (!prfResults?.results?.first) {
|
|
2958
|
+
throw new Error(
|
|
2959
|
+
"PRF extension did not return a result. The authenticator may not support PRF."
|
|
2960
|
+
);
|
|
2961
|
+
}
|
|
2962
|
+
return {
|
|
2963
|
+
prfOutput: prfResults.results.first,
|
|
2964
|
+
credentialId: new Uint8Array(credential.rawId),
|
|
2965
|
+
userHandle: response.userHandle ? new Uint8Array(response.userHandle) : null
|
|
2966
|
+
};
|
|
2967
|
+
}
|
|
2968
|
+
function isPrfSupported() {
|
|
2969
|
+
if (typeof window === "undefined") return false;
|
|
2970
|
+
if (typeof navigator === "undefined") return false;
|
|
2971
|
+
if (!navigator.credentials) return false;
|
|
2972
|
+
if (typeof PublicKeyCredential === "undefined") return false;
|
|
2973
|
+
return true;
|
|
2974
|
+
}
|
|
2975
|
+
async function hashCredentialId(credentialId) {
|
|
2976
|
+
const arrayBuffer = credentialId.buffer.slice(
|
|
2977
|
+
credentialId.byteOffset,
|
|
2978
|
+
credentialId.byteOffset + credentialId.byteLength
|
|
2979
|
+
);
|
|
2980
|
+
const hash = await crypto.subtle.digest("SHA-256", arrayBuffer);
|
|
2981
|
+
return Array.from(new Uint8Array(hash)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
2982
|
+
}
|
|
2983
|
+
|
|
2984
|
+
// src/keys/encoding.ts
|
|
2985
|
+
function toBase642(data) {
|
|
2986
|
+
const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
|
|
2987
|
+
let binary = "";
|
|
2988
|
+
for (const b of bytes) binary += String.fromCharCode(b);
|
|
2989
|
+
return btoa(binary);
|
|
2990
|
+
}
|
|
2991
|
+
function fromBase642(b64) {
|
|
2992
|
+
const binary = atob(b64);
|
|
2993
|
+
const bytes = new Uint8Array(binary.length);
|
|
2994
|
+
for (let i = 0; i < binary.length; i++) {
|
|
2995
|
+
bytes[i] = binary.charCodeAt(i);
|
|
2996
|
+
}
|
|
2997
|
+
return bytes;
|
|
2998
|
+
}
|
|
2999
|
+
function hexToBytes(hex) {
|
|
3000
|
+
const bytes = new Uint8Array(hex.length / 2);
|
|
3001
|
+
for (let i = 0; i < hex.length; i += 2) {
|
|
3002
|
+
bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);
|
|
3003
|
+
}
|
|
3004
|
+
return bytes;
|
|
3005
|
+
}
|
|
3006
|
+
function bytesToHex(bytes) {
|
|
3007
|
+
return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
3008
|
+
}
|
|
3009
|
+
|
|
3010
|
+
// src/keys/KeyVault.ts
|
|
3011
|
+
async function generateDek() {
|
|
3012
|
+
return crypto.subtle.generateKey(
|
|
3013
|
+
{ name: "AES-GCM", length: 256 },
|
|
3014
|
+
true,
|
|
3015
|
+
// extractable — needed for AES-KW wrapping
|
|
3016
|
+
["encrypt", "decrypt"]
|
|
3017
|
+
);
|
|
3018
|
+
}
|
|
3019
|
+
async function encryptMnemonic(dek, mnemonic) {
|
|
3020
|
+
const encoder = new TextEncoder();
|
|
3021
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
3022
|
+
const ciphertext = await crypto.subtle.encrypt(
|
|
3023
|
+
{ name: "AES-GCM", iv },
|
|
3024
|
+
dek,
|
|
3025
|
+
encoder.encode(mnemonic)
|
|
3026
|
+
);
|
|
3027
|
+
return {
|
|
3028
|
+
encryptedMnemonic: toBase642(ciphertext),
|
|
3029
|
+
iv: toBase642(iv)
|
|
3030
|
+
};
|
|
3031
|
+
}
|
|
3032
|
+
async function decryptMnemonic(dek, encryptedMnemonic, iv) {
|
|
3033
|
+
const decoder = new TextDecoder();
|
|
3034
|
+
const plaintext = await crypto.subtle.decrypt(
|
|
3035
|
+
{ name: "AES-GCM", iv: fromBase642(iv) },
|
|
3036
|
+
dek,
|
|
3037
|
+
fromBase642(encryptedMnemonic)
|
|
3038
|
+
);
|
|
3039
|
+
return decoder.decode(plaintext);
|
|
3040
|
+
}
|
|
3041
|
+
async function deriveKek(prfOutput) {
|
|
3042
|
+
const keyMaterial = await crypto.subtle.importKey(
|
|
3043
|
+
"raw",
|
|
3044
|
+
prfOutput,
|
|
3045
|
+
"HKDF",
|
|
3046
|
+
false,
|
|
3047
|
+
["deriveKey"]
|
|
3048
|
+
);
|
|
3049
|
+
const encoder = new TextEncoder();
|
|
3050
|
+
return crypto.subtle.deriveKey(
|
|
3051
|
+
{
|
|
3052
|
+
name: "HKDF",
|
|
3053
|
+
hash: "SHA-256",
|
|
3054
|
+
salt: new Uint8Array(0),
|
|
3055
|
+
// PRF salt was already applied at the WebAuthn level
|
|
3056
|
+
info: encoder.encode("toon:kek")
|
|
3057
|
+
},
|
|
3058
|
+
keyMaterial,
|
|
3059
|
+
{ name: "AES-KW", length: 256 },
|
|
3060
|
+
false,
|
|
3061
|
+
// not extractable
|
|
3062
|
+
["wrapKey", "unwrapKey"]
|
|
3063
|
+
);
|
|
3064
|
+
}
|
|
3065
|
+
async function deriveKekFromPassword(password, salt) {
|
|
3066
|
+
const encoder = new TextEncoder();
|
|
3067
|
+
const keyMaterial = await crypto.subtle.importKey(
|
|
3068
|
+
"raw",
|
|
3069
|
+
encoder.encode(password),
|
|
3070
|
+
"PBKDF2",
|
|
3071
|
+
false,
|
|
3072
|
+
["deriveKey"]
|
|
3073
|
+
);
|
|
3074
|
+
return crypto.subtle.deriveKey(
|
|
3075
|
+
{
|
|
3076
|
+
name: "PBKDF2",
|
|
3077
|
+
hash: "SHA-256",
|
|
3078
|
+
salt,
|
|
3079
|
+
iterations: 6e5
|
|
3080
|
+
// OWASP 2023 recommendation for SHA-256
|
|
3081
|
+
},
|
|
3082
|
+
keyMaterial,
|
|
3083
|
+
{ name: "AES-KW", length: 256 },
|
|
3084
|
+
false,
|
|
3085
|
+
["wrapKey", "unwrapKey"]
|
|
3086
|
+
);
|
|
3087
|
+
}
|
|
3088
|
+
async function wrapDek(kek, dek) {
|
|
3089
|
+
const wrapped = await crypto.subtle.wrapKey("raw", dek, kek, "AES-KW");
|
|
3090
|
+
return toBase642(new Uint8Array(wrapped));
|
|
3091
|
+
}
|
|
3092
|
+
async function unwrapDek(kek, wrappedDek) {
|
|
3093
|
+
return crypto.subtle.unwrapKey(
|
|
3094
|
+
"raw",
|
|
3095
|
+
fromBase642(wrappedDek),
|
|
3096
|
+
kek,
|
|
3097
|
+
"AES-KW",
|
|
3098
|
+
{ name: "AES-GCM", length: 256 },
|
|
3099
|
+
true,
|
|
3100
|
+
// extractable — needed for re-wrapping when adding new KEKs
|
|
3101
|
+
["encrypt", "decrypt"]
|
|
3102
|
+
);
|
|
3103
|
+
}
|
|
3104
|
+
async function createVault(mnemonic, kek, credentialIdHash, prfSalt) {
|
|
3105
|
+
const dek = await generateDek();
|
|
3106
|
+
const { encryptedMnemonic, iv } = await encryptMnemonic(dek, mnemonic);
|
|
3107
|
+
const wrappedDek = await wrapDek(kek, dek);
|
|
3108
|
+
const entry = {
|
|
3109
|
+
id: credentialIdHash,
|
|
3110
|
+
wrapped_dek: wrappedDek,
|
|
3111
|
+
salt: toBase642(prfSalt),
|
|
3112
|
+
created_at: Math.floor(Date.now() / 1e3)
|
|
3113
|
+
};
|
|
3114
|
+
return {
|
|
3115
|
+
encryptedMnemonic,
|
|
3116
|
+
iv,
|
|
3117
|
+
wrappedKeys: [entry]
|
|
3118
|
+
};
|
|
3119
|
+
}
|
|
3120
|
+
async function unlockVault(vault, kek, credentialIdHash) {
|
|
3121
|
+
const entry = vault.wrappedKeys.find((e) => e.id === credentialIdHash);
|
|
3122
|
+
if (!entry) {
|
|
3123
|
+
throw new Error(`No wrapped key found for credential ${credentialIdHash}`);
|
|
3124
|
+
}
|
|
3125
|
+
const dek = await unwrapDek(kek, entry.wrapped_dek);
|
|
3126
|
+
return decryptMnemonic(dek, vault.encryptedMnemonic, vault.iv);
|
|
3127
|
+
}
|
|
3128
|
+
async function addKekToVault(vault, existingKek, existingCredentialIdHash, newKek, newCredentialIdHash, newPrfSalt) {
|
|
3129
|
+
const existingEntry = vault.wrappedKeys.find(
|
|
3130
|
+
(e) => e.id === existingCredentialIdHash
|
|
3131
|
+
);
|
|
3132
|
+
if (!existingEntry) {
|
|
3133
|
+
throw new Error(
|
|
3134
|
+
`No wrapped key found for credential ${existingCredentialIdHash}`
|
|
3135
|
+
);
|
|
3136
|
+
}
|
|
3137
|
+
const dek = await unwrapDek(existingKek, existingEntry.wrapped_dek);
|
|
3138
|
+
const newWrappedDek = await wrapDek(newKek, dek);
|
|
3139
|
+
const newEntry = {
|
|
3140
|
+
id: newCredentialIdHash,
|
|
3141
|
+
wrapped_dek: newWrappedDek,
|
|
3142
|
+
salt: toBase642(newPrfSalt),
|
|
3143
|
+
created_at: Math.floor(Date.now() / 1e3)
|
|
3144
|
+
};
|
|
3145
|
+
return {
|
|
3146
|
+
...vault,
|
|
3147
|
+
wrappedKeys: [...vault.wrappedKeys, newEntry]
|
|
3148
|
+
};
|
|
3149
|
+
}
|
|
3150
|
+
function removeKekFromVault(vault, credentialIdHash) {
|
|
3151
|
+
const remaining = vault.wrappedKeys.filter((e) => e.id !== credentialIdHash);
|
|
3152
|
+
if (remaining.length === 0) {
|
|
3153
|
+
throw new Error(
|
|
3154
|
+
"Cannot remove the last passkey \u2014 at least one passkey must remain for vault access"
|
|
3155
|
+
);
|
|
3156
|
+
}
|
|
3157
|
+
return {
|
|
3158
|
+
...vault,
|
|
3159
|
+
wrappedKeys: remaining
|
|
3160
|
+
};
|
|
3161
|
+
}
|
|
3162
|
+
async function addRecoveryCodeToVault(vault, existingKek, existingCredentialIdHash, recoveryKek, recoverySalt) {
|
|
3163
|
+
const existingEntry = vault.wrappedKeys.find(
|
|
3164
|
+
(e) => e.id === existingCredentialIdHash
|
|
3165
|
+
);
|
|
3166
|
+
if (!existingEntry) {
|
|
3167
|
+
throw new Error(
|
|
3168
|
+
`No wrapped key found for credential ${existingCredentialIdHash}`
|
|
3169
|
+
);
|
|
3170
|
+
}
|
|
3171
|
+
const dek = await unwrapDek(existingKek, existingEntry.wrapped_dek);
|
|
3172
|
+
const recoveryWrappedDek = await wrapDek(recoveryKek, dek);
|
|
3173
|
+
return {
|
|
3174
|
+
...vault,
|
|
3175
|
+
recoveryCodeWrappedDek: recoveryWrappedDek,
|
|
3176
|
+
recoveryCodeSalt: toBase642(recoverySalt)
|
|
3177
|
+
};
|
|
3178
|
+
}
|
|
3179
|
+
async function unlockVaultWithRecoveryCode(vault, recoveryKek) {
|
|
3180
|
+
if (!vault.recoveryCodeWrappedDek) {
|
|
3181
|
+
throw new Error("No recovery code is configured for this vault");
|
|
3182
|
+
}
|
|
3183
|
+
const dek = await unwrapDek(recoveryKek, vault.recoveryCodeWrappedDek);
|
|
3184
|
+
return decryptMnemonic(dek, vault.encryptedMnemonic, vault.iv);
|
|
3185
|
+
}
|
|
3186
|
+
function generateRecoveryCode() {
|
|
3187
|
+
const bytes = crypto.getRandomValues(new Uint8Array(12));
|
|
3188
|
+
const hex = Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
3189
|
+
const groups = hex.match(/.{4}/g) ?? [];
|
|
3190
|
+
return groups.join("-");
|
|
3191
|
+
}
|
|
3192
|
+
|
|
3193
|
+
// src/keys/BackupService.ts
|
|
3194
|
+
import { getPublicKey as getPublicKey3 } from "nostr-tools/pure";
|
|
3195
|
+
var BACKUP_KIND = 30078;
|
|
3196
|
+
var BACKUP_D_TAG = "toon:identity-backup";
|
|
3197
|
+
var BACKUP_VERSION = "1";
|
|
3198
|
+
function buildBackupEvent(vault, secretKey, chains = "nostr,evm,solana,mina") {
|
|
3199
|
+
const pubkey = getPublicKey3(secretKey);
|
|
3200
|
+
const payload = {
|
|
3201
|
+
encrypted_mnemonic: vault.encryptedMnemonic,
|
|
3202
|
+
wrapped_keys: vault.wrappedKeys,
|
|
3203
|
+
iv: vault.iv,
|
|
3204
|
+
...vault.recoveryCodeWrappedDek && {
|
|
3205
|
+
recovery_code_wrapped_dek: vault.recoveryCodeWrappedDek
|
|
3206
|
+
},
|
|
3207
|
+
...vault.recoveryCodeSalt && {
|
|
3208
|
+
recovery_code_salt: vault.recoveryCodeSalt
|
|
3209
|
+
}
|
|
3210
|
+
};
|
|
3211
|
+
return {
|
|
3212
|
+
kind: BACKUP_KIND,
|
|
3213
|
+
pubkey,
|
|
3214
|
+
created_at: Math.floor(Date.now() / 1e3),
|
|
3215
|
+
tags: [
|
|
3216
|
+
["d", BACKUP_D_TAG],
|
|
3217
|
+
["v", BACKUP_VERSION],
|
|
3218
|
+
["chains", chains]
|
|
3219
|
+
],
|
|
3220
|
+
content: JSON.stringify(payload)
|
|
3221
|
+
};
|
|
3222
|
+
}
|
|
3223
|
+
function buildBackupFilter(pubkey) {
|
|
3224
|
+
return {
|
|
3225
|
+
kinds: [BACKUP_KIND],
|
|
3226
|
+
authors: [pubkey],
|
|
3227
|
+
"#d": [BACKUP_D_TAG]
|
|
3228
|
+
};
|
|
3229
|
+
}
|
|
3230
|
+
function parseBackupPayload(content) {
|
|
3231
|
+
let parsed;
|
|
3232
|
+
try {
|
|
3233
|
+
parsed = JSON.parse(content);
|
|
3234
|
+
} catch {
|
|
3235
|
+
throw new Error("Invalid backup event content: not valid JSON");
|
|
3236
|
+
}
|
|
3237
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
3238
|
+
throw new Error("Invalid backup event content: not an object");
|
|
3239
|
+
}
|
|
3240
|
+
const payload = parsed;
|
|
3241
|
+
if (typeof payload["encrypted_mnemonic"] !== "string") {
|
|
3242
|
+
throw new Error("Invalid backup: missing encrypted_mnemonic");
|
|
3243
|
+
}
|
|
3244
|
+
if (typeof payload["iv"] !== "string") {
|
|
3245
|
+
throw new Error("Invalid backup: missing iv");
|
|
3246
|
+
}
|
|
3247
|
+
if (!Array.isArray(payload["wrapped_keys"])) {
|
|
3248
|
+
throw new Error("Invalid backup: missing wrapped_keys array");
|
|
3249
|
+
}
|
|
3250
|
+
for (const entry of payload["wrapped_keys"]) {
|
|
3251
|
+
if (typeof entry !== "object" || entry === null) {
|
|
3252
|
+
throw new Error("Invalid backup: wrapped_keys entry is not an object");
|
|
3253
|
+
}
|
|
3254
|
+
const e = entry;
|
|
3255
|
+
if (typeof e["id"] !== "string") {
|
|
3256
|
+
throw new Error("Invalid backup: wrapped key missing id");
|
|
3257
|
+
}
|
|
3258
|
+
if (typeof e["wrapped_dek"] !== "string") {
|
|
3259
|
+
throw new Error("Invalid backup: wrapped key missing wrapped_dek");
|
|
3260
|
+
}
|
|
3261
|
+
if (typeof e["salt"] !== "string") {
|
|
3262
|
+
throw new Error("Invalid backup: wrapped key missing salt");
|
|
3263
|
+
}
|
|
3264
|
+
if (typeof e["created_at"] !== "number") {
|
|
3265
|
+
throw new Error(
|
|
3266
|
+
"Invalid backup: wrapped key missing or invalid created_at"
|
|
3267
|
+
);
|
|
3268
|
+
}
|
|
3269
|
+
}
|
|
3270
|
+
return {
|
|
3271
|
+
encryptedMnemonic: payload["encrypted_mnemonic"],
|
|
3272
|
+
iv: payload["iv"],
|
|
3273
|
+
wrappedKeys: payload["wrapped_keys"],
|
|
3274
|
+
...typeof payload["recovery_code_wrapped_dek"] === "string" && {
|
|
3275
|
+
recoveryCodeWrappedDek: payload["recovery_code_wrapped_dek"]
|
|
3276
|
+
},
|
|
3277
|
+
...typeof payload["recovery_code_salt"] === "string" && {
|
|
3278
|
+
recoveryCodeSalt: payload["recovery_code_salt"]
|
|
3279
|
+
}
|
|
3280
|
+
};
|
|
3281
|
+
}
|
|
3282
|
+
async function publishBackupToRelays(signedEvent, relayUrls) {
|
|
3283
|
+
const { SimplePool } = await import("nostr-tools/pool");
|
|
3284
|
+
const pool = new SimplePool();
|
|
3285
|
+
try {
|
|
3286
|
+
await Promise.allSettled(
|
|
3287
|
+
relayUrls.map((url) => pool.publish([url], signedEvent))
|
|
3288
|
+
);
|
|
3289
|
+
} finally {
|
|
3290
|
+
pool.close(relayUrls);
|
|
3291
|
+
}
|
|
3292
|
+
}
|
|
3293
|
+
async function fetchBackupFromRelays(pubkey, relayUrls) {
|
|
3294
|
+
const { SimplePool } = await import("nostr-tools/pool");
|
|
3295
|
+
const pool = new SimplePool();
|
|
3296
|
+
try {
|
|
3297
|
+
const filter = buildBackupFilter(pubkey);
|
|
3298
|
+
const events = await pool.querySync(relayUrls, filter);
|
|
3299
|
+
if (!events || events.length === 0) {
|
|
3300
|
+
return null;
|
|
3301
|
+
}
|
|
3302
|
+
events.sort(
|
|
3303
|
+
(a, b) => b.created_at - a.created_at
|
|
3304
|
+
);
|
|
3305
|
+
const latest = events[0];
|
|
3306
|
+
if (!latest) return null;
|
|
3307
|
+
return parseBackupPayload(latest.content);
|
|
3308
|
+
} finally {
|
|
3309
|
+
pool.close(relayUrls);
|
|
3310
|
+
}
|
|
3311
|
+
}
|
|
3312
|
+
|
|
3313
|
+
// src/keys/KeyManager.ts
|
|
3314
|
+
var KeyManager = class {
|
|
3315
|
+
config;
|
|
3316
|
+
identity = null;
|
|
3317
|
+
vault = null;
|
|
3318
|
+
activeCredentialIdHash = null;
|
|
3319
|
+
constructor(config) {
|
|
3320
|
+
if (!config.relayUrls || config.relayUrls.length === 0) {
|
|
3321
|
+
throw new Error("KeyManager requires at least one relay URL");
|
|
3322
|
+
}
|
|
3323
|
+
this.config = {
|
|
3324
|
+
relayUrls: config.relayUrls,
|
|
3325
|
+
rpId: config.rpId ?? (typeof window !== "undefined" ? window.location.hostname : "localhost"),
|
|
3326
|
+
rpName: config.rpName ?? "TOON Protocol",
|
|
3327
|
+
storageKey: config.storageKey ?? "toon:keys"
|
|
3328
|
+
};
|
|
3329
|
+
}
|
|
3330
|
+
// --- Account Lifecycle ---
|
|
3331
|
+
/**
|
|
3332
|
+
* Create a new account: generate mnemonic, create Passkey, encrypt, backup.
|
|
3333
|
+
*/
|
|
3334
|
+
async create() {
|
|
3335
|
+
const mnemonic = generateMnemonic();
|
|
3336
|
+
const identity = await deriveFullIdentity(mnemonic);
|
|
3337
|
+
const prfSalt = crypto.getRandomValues(new Uint8Array(32));
|
|
3338
|
+
const userIdBytes = hexToBytes(identity.nostr.pubkey);
|
|
3339
|
+
const registration = await registerPasskey({
|
|
3340
|
+
rpId: this.config.rpId,
|
|
3341
|
+
rpName: this.config.rpName,
|
|
3342
|
+
userId: userIdBytes,
|
|
3343
|
+
userName: `TOON ${identity.nostr.pubkey.slice(0, 8)}`,
|
|
3344
|
+
prfSalt
|
|
3345
|
+
});
|
|
3346
|
+
const kek = await deriveKek(registration.prfOutput);
|
|
3347
|
+
const credIdHash = await hashCredentialId(registration.credentialId);
|
|
3348
|
+
this.vault = await createVault(mnemonic, kek, credIdHash, prfSalt);
|
|
3349
|
+
this.identity = identity;
|
|
3350
|
+
this.activeCredentialIdHash = credIdHash;
|
|
3351
|
+
await this.saveToLocalStorage();
|
|
3352
|
+
await this.backupToRelay().catch(() => {
|
|
3353
|
+
});
|
|
3354
|
+
return identity;
|
|
3355
|
+
}
|
|
3356
|
+
/**
|
|
3357
|
+
* Recover an account using a synced Passkey.
|
|
3358
|
+
* The Nostr pubkey is extracted from the Passkey's userHandle.
|
|
3359
|
+
*
|
|
3360
|
+
* Flow: single assertion → userHandle → fetch backup → derive KEK → unlock.
|
|
3361
|
+
* If the local vault is available (has the PRF salt), we use a single assertion
|
|
3362
|
+
* with the correct salt. Otherwise, we need the backup from relays first, which
|
|
3363
|
+
* requires a discovery assertion to get the pubkey.
|
|
3364
|
+
*/
|
|
3365
|
+
async recover() {
|
|
3366
|
+
const localVault = await this.loadFromLocalStorage();
|
|
3367
|
+
if (localVault) {
|
|
3368
|
+
return this.unlockWithVault(localVault);
|
|
3369
|
+
}
|
|
3370
|
+
const discovery = await assertPasskey({
|
|
3371
|
+
rpId: this.config.rpId,
|
|
3372
|
+
prfSalt: crypto.getRandomValues(new Uint8Array(32))
|
|
3373
|
+
// Dummy salt for discovery
|
|
3374
|
+
});
|
|
3375
|
+
if (!discovery.userHandle || discovery.userHandle.length === 0) {
|
|
3376
|
+
throw new Error(
|
|
3377
|
+
"Passkey did not return a userHandle. Cannot determine Nostr pubkey for recovery."
|
|
3378
|
+
);
|
|
3379
|
+
}
|
|
3380
|
+
const pubkey = bytesToHex(discovery.userHandle);
|
|
3381
|
+
const vault = await fetchBackupFromRelays(pubkey, this.config.relayUrls);
|
|
3382
|
+
if (!vault) {
|
|
3383
|
+
throw new Error(
|
|
3384
|
+
"No backup found on configured relays for this identity. Try importing with a mnemonic or nsec instead."
|
|
3385
|
+
);
|
|
3386
|
+
}
|
|
3387
|
+
const credIdHash = await hashCredentialId(discovery.credentialId);
|
|
3388
|
+
const entry = vault.wrappedKeys.find((e) => e.id === credIdHash);
|
|
3389
|
+
if (!entry) {
|
|
3390
|
+
throw new Error(
|
|
3391
|
+
"This Passkey is not registered with the backup. Try a different Passkey or use a recovery code."
|
|
3392
|
+
);
|
|
3393
|
+
}
|
|
3394
|
+
const saltBytes = fromBase642(entry.salt);
|
|
3395
|
+
const reassertion = await assertPasskey({
|
|
3396
|
+
rpId: this.config.rpId,
|
|
3397
|
+
prfSalt: saltBytes,
|
|
3398
|
+
allowCredentials: [discovery.credentialId]
|
|
3399
|
+
});
|
|
3400
|
+
const kek = await deriveKek(reassertion.prfOutput);
|
|
3401
|
+
const mnemonic = await unlockVault(vault, kek, credIdHash);
|
|
3402
|
+
const identity = await deriveFullIdentity(mnemonic);
|
|
3403
|
+
this.vault = vault;
|
|
3404
|
+
this.identity = identity;
|
|
3405
|
+
this.activeCredentialIdHash = credIdHash;
|
|
3406
|
+
await this.saveToLocalStorage();
|
|
3407
|
+
return identity;
|
|
3408
|
+
}
|
|
3409
|
+
/**
|
|
3410
|
+
* Import an existing BIP-39 mnemonic. Creates a Passkey and backup.
|
|
3411
|
+
*/
|
|
3412
|
+
async importMnemonic(mnemonic) {
|
|
3413
|
+
if (!validateMnemonic(mnemonic)) {
|
|
3414
|
+
throw new Error("Invalid BIP-39 mnemonic phrase");
|
|
3415
|
+
}
|
|
3416
|
+
const identity = await deriveFullIdentity(mnemonic);
|
|
3417
|
+
const prfSalt = crypto.getRandomValues(new Uint8Array(32));
|
|
3418
|
+
const userIdBytes = hexToBytes(identity.nostr.pubkey);
|
|
3419
|
+
const registration = await registerPasskey({
|
|
3420
|
+
rpId: this.config.rpId,
|
|
3421
|
+
rpName: this.config.rpName,
|
|
3422
|
+
userId: userIdBytes,
|
|
3423
|
+
userName: `TOON ${identity.nostr.pubkey.slice(0, 8)}`,
|
|
3424
|
+
prfSalt
|
|
3425
|
+
});
|
|
3426
|
+
const kek = await deriveKek(registration.prfOutput);
|
|
3427
|
+
const credIdHash = await hashCredentialId(registration.credentialId);
|
|
3428
|
+
this.vault = await createVault(mnemonic, kek, credIdHash, prfSalt);
|
|
3429
|
+
this.identity = identity;
|
|
3430
|
+
this.activeCredentialIdHash = credIdHash;
|
|
3431
|
+
await this.saveToLocalStorage();
|
|
3432
|
+
await this.backupToRelay().catch(() => {
|
|
3433
|
+
});
|
|
3434
|
+
return identity;
|
|
3435
|
+
}
|
|
3436
|
+
/**
|
|
3437
|
+
* Import from an nsec (Nostr-only key).
|
|
3438
|
+
* Nostr + EVM are derived; Solana + Mina get fresh keys (not deterministically linked).
|
|
3439
|
+
*/
|
|
3440
|
+
async importNsec(nsec) {
|
|
3441
|
+
const decoded = nip19.decode(nsec);
|
|
3442
|
+
if (decoded.type !== "nsec") {
|
|
3443
|
+
throw new Error("Invalid nsec string");
|
|
3444
|
+
}
|
|
3445
|
+
const secretKey = decoded.data;
|
|
3446
|
+
const identity = deriveFromNsec(secretKey);
|
|
3447
|
+
if (isPrfSupported()) {
|
|
3448
|
+
const prfSalt = crypto.getRandomValues(new Uint8Array(32));
|
|
3449
|
+
const userIdBytes = hexToBytes(identity.nostr.pubkey);
|
|
3450
|
+
try {
|
|
3451
|
+
const registration = await registerPasskey({
|
|
3452
|
+
rpId: this.config.rpId,
|
|
3453
|
+
rpName: this.config.rpName,
|
|
3454
|
+
userId: userIdBytes,
|
|
3455
|
+
userName: `TOON ${identity.nostr.pubkey.slice(0, 8)}`,
|
|
3456
|
+
prfSalt
|
|
3457
|
+
});
|
|
3458
|
+
const kek = await deriveKek(registration.prfOutput);
|
|
3459
|
+
const credIdHash = await hashCredentialId(registration.credentialId);
|
|
3460
|
+
const hexKey = bytesToHex(secretKey);
|
|
3461
|
+
this.vault = await createVault(hexKey, kek, credIdHash, prfSalt);
|
|
3462
|
+
this.activeCredentialIdHash = credIdHash;
|
|
3463
|
+
await this.saveToLocalStorage();
|
|
3464
|
+
} catch {
|
|
3465
|
+
}
|
|
3466
|
+
}
|
|
3467
|
+
this.identity = identity;
|
|
3468
|
+
return identity;
|
|
3469
|
+
}
|
|
3470
|
+
// --- Passkey Management ---
|
|
3471
|
+
/**
|
|
3472
|
+
* Register an additional Passkey for this identity.
|
|
3473
|
+
*/
|
|
3474
|
+
async addPasskey() {
|
|
3475
|
+
if (!this.identity || !this.vault || !this.activeCredentialIdHash) {
|
|
3476
|
+
throw new Error("No active identity \u2014 call create() or recover() first");
|
|
3477
|
+
}
|
|
3478
|
+
const prfSalt = crypto.getRandomValues(new Uint8Array(32));
|
|
3479
|
+
const userIdBytes = hexToBytes(this.identity.nostr.pubkey);
|
|
3480
|
+
const registration = await registerPasskey({
|
|
3481
|
+
rpId: this.config.rpId,
|
|
3482
|
+
rpName: this.config.rpName,
|
|
3483
|
+
userId: userIdBytes,
|
|
3484
|
+
userName: `TOON ${this.identity.nostr.pubkey.slice(0, 8)}`,
|
|
3485
|
+
prfSalt
|
|
3486
|
+
});
|
|
3487
|
+
const newKek = await deriveKek(registration.prfOutput);
|
|
3488
|
+
const newCredIdHash = await hashCredentialId(registration.credentialId);
|
|
3489
|
+
const currentEntry = this.vault.wrappedKeys.find(
|
|
3490
|
+
(e) => e.id === this.activeCredentialIdHash
|
|
3491
|
+
);
|
|
3492
|
+
if (!currentEntry) {
|
|
3493
|
+
throw new Error("Active credential not found in vault");
|
|
3494
|
+
}
|
|
3495
|
+
const currentSaltBytes = fromBase642(currentEntry.salt);
|
|
3496
|
+
const currentAssertion = await assertPasskey({
|
|
3497
|
+
rpId: this.config.rpId,
|
|
3498
|
+
prfSalt: currentSaltBytes
|
|
3499
|
+
});
|
|
3500
|
+
const currentKek = await deriveKek(currentAssertion.prfOutput);
|
|
3501
|
+
this.vault = await addKekToVault(
|
|
3502
|
+
this.vault,
|
|
3503
|
+
currentKek,
|
|
3504
|
+
this.activeCredentialIdHash,
|
|
3505
|
+
newKek,
|
|
3506
|
+
newCredIdHash,
|
|
3507
|
+
prfSalt
|
|
3508
|
+
);
|
|
3509
|
+
await this.saveToLocalStorage();
|
|
3510
|
+
await this.backupToRelay().catch(() => {
|
|
3511
|
+
});
|
|
3512
|
+
}
|
|
3513
|
+
/**
|
|
3514
|
+
* List registered Passkey credentials.
|
|
3515
|
+
*/
|
|
3516
|
+
listPasskeys() {
|
|
3517
|
+
if (!this.vault) return [];
|
|
3518
|
+
return this.vault.wrappedKeys.map((entry) => ({
|
|
3519
|
+
credentialIdHash: entry.id,
|
|
3520
|
+
createdAt: entry.created_at
|
|
3521
|
+
}));
|
|
3522
|
+
}
|
|
3523
|
+
/**
|
|
3524
|
+
* Remove a Passkey from the vault. Cannot remove the last one.
|
|
3525
|
+
*/
|
|
3526
|
+
async removePasskey(credentialIdHash) {
|
|
3527
|
+
if (!this.vault) {
|
|
3528
|
+
throw new Error("No active vault");
|
|
3529
|
+
}
|
|
3530
|
+
this.vault = removeKekFromVault(this.vault, credentialIdHash);
|
|
3531
|
+
if (this.activeCredentialIdHash === credentialIdHash) {
|
|
3532
|
+
const remaining = this.vault.wrappedKeys[0];
|
|
3533
|
+
this.activeCredentialIdHash = remaining ? remaining.id : null;
|
|
3534
|
+
}
|
|
3535
|
+
await this.saveToLocalStorage();
|
|
3536
|
+
await this.backupToRelay().catch(() => {
|
|
3537
|
+
});
|
|
3538
|
+
}
|
|
3539
|
+
// --- Recovery ---
|
|
3540
|
+
/**
|
|
3541
|
+
* Generate a printable recovery code and add it to the vault.
|
|
3542
|
+
* The PBKDF2 salt is persisted alongside the wrapped DEK so the code
|
|
3543
|
+
* can be verified later without the original salt.
|
|
3544
|
+
*
|
|
3545
|
+
* @returns The recovery code — user must store it securely.
|
|
3546
|
+
*/
|
|
3547
|
+
async generateRecoveryCode() {
|
|
3548
|
+
if (!this.vault || !this.activeCredentialIdHash) {
|
|
3549
|
+
throw new Error("No active vault");
|
|
3550
|
+
}
|
|
3551
|
+
const code = generateRecoveryCode();
|
|
3552
|
+
const salt = crypto.getRandomValues(new Uint8Array(16));
|
|
3553
|
+
const recoveryKek = await deriveKekFromPassword(code, salt);
|
|
3554
|
+
const currentEntry = this.vault.wrappedKeys.find(
|
|
3555
|
+
(e) => e.id === this.activeCredentialIdHash
|
|
3556
|
+
);
|
|
3557
|
+
if (!currentEntry) {
|
|
3558
|
+
throw new Error("Active credential not found in vault");
|
|
3559
|
+
}
|
|
3560
|
+
const currentSaltBytes = fromBase642(currentEntry.salt);
|
|
3561
|
+
const currentAssertion = await assertPasskey({
|
|
3562
|
+
rpId: this.config.rpId,
|
|
3563
|
+
prfSalt: currentSaltBytes
|
|
3564
|
+
});
|
|
3565
|
+
const currentKek = await deriveKek(currentAssertion.prfOutput);
|
|
3566
|
+
this.vault = await addRecoveryCodeToVault(
|
|
3567
|
+
this.vault,
|
|
3568
|
+
currentKek,
|
|
3569
|
+
this.activeCredentialIdHash,
|
|
3570
|
+
recoveryKek,
|
|
3571
|
+
salt
|
|
3572
|
+
);
|
|
3573
|
+
await this.saveToLocalStorage();
|
|
3574
|
+
await this.backupToRelay().catch(() => {
|
|
3575
|
+
});
|
|
3576
|
+
return code;
|
|
3577
|
+
}
|
|
3578
|
+
/**
|
|
3579
|
+
* Recover identity using a recovery code.
|
|
3580
|
+
* The PBKDF2 salt is read from the persisted vault data.
|
|
3581
|
+
*/
|
|
3582
|
+
async recoverWithCode(code) {
|
|
3583
|
+
const vault = await this.loadFromLocalStorage();
|
|
3584
|
+
if (!vault) {
|
|
3585
|
+
throw new Error(
|
|
3586
|
+
"No local vault found. Recovery code requires the encrypted vault. If you have a Passkey, use recover() to fetch from relays first."
|
|
3587
|
+
);
|
|
3588
|
+
}
|
|
3589
|
+
if (!vault.recoveryCodeWrappedDek || !vault.recoveryCodeSalt) {
|
|
3590
|
+
throw new Error("No recovery code configured for this vault");
|
|
3591
|
+
}
|
|
3592
|
+
const salt = fromBase642(vault.recoveryCodeSalt);
|
|
3593
|
+
const recoveryKek = await deriveKekFromPassword(code, salt);
|
|
3594
|
+
const mnemonic = await unlockVaultWithRecoveryCode(vault, recoveryKek);
|
|
3595
|
+
const identity = await deriveFullIdentity(mnemonic);
|
|
3596
|
+
this.vault = vault;
|
|
3597
|
+
this.identity = identity;
|
|
3598
|
+
return identity;
|
|
3599
|
+
}
|
|
3600
|
+
// --- Key Access ---
|
|
3601
|
+
/**
|
|
3602
|
+
* Get the current identity, or null if not unlocked.
|
|
3603
|
+
*/
|
|
3604
|
+
getIdentity() {
|
|
3605
|
+
return this.identity;
|
|
3606
|
+
}
|
|
3607
|
+
/**
|
|
3608
|
+
* Get the Nostr secret key. Throws if not unlocked.
|
|
3609
|
+
*/
|
|
3610
|
+
getNostrSecretKey() {
|
|
3611
|
+
if (!this.identity) throw new Error("Identity not unlocked");
|
|
3612
|
+
return this.identity.nostr.secretKey;
|
|
3613
|
+
}
|
|
3614
|
+
/**
|
|
3615
|
+
* Get an EvmSigner instance. Throws if not unlocked.
|
|
3616
|
+
*/
|
|
3617
|
+
getEvmSigner() {
|
|
3618
|
+
if (!this.identity) throw new Error("Identity not unlocked");
|
|
3619
|
+
return new EvmSigner(this.identity.evm.privateKey);
|
|
3620
|
+
}
|
|
3621
|
+
/**
|
|
3622
|
+
* Get a SolanaSigner instance. Throws if not unlocked or Solana not derived.
|
|
3623
|
+
*/
|
|
3624
|
+
getSolanaSigner() {
|
|
3625
|
+
if (!this.identity) throw new Error("Identity not unlocked");
|
|
3626
|
+
if (!this.identity.solana.publicKey) {
|
|
3627
|
+
throw new Error(
|
|
3628
|
+
"Solana keys not available \u2014 was this imported from nsec?"
|
|
3629
|
+
);
|
|
3630
|
+
}
|
|
3631
|
+
return new SolanaSigner(this.identity.solana.secretKey);
|
|
3632
|
+
}
|
|
3633
|
+
/**
|
|
3634
|
+
* Get a MinaSigner instance. Throws if not unlocked or Mina not derived.
|
|
3635
|
+
*/
|
|
3636
|
+
getMinaSigner() {
|
|
3637
|
+
if (!this.identity) throw new Error("Identity not unlocked");
|
|
3638
|
+
if (!this.identity.mina.publicKey) {
|
|
3639
|
+
throw new Error("Mina keys not available \u2014 was this imported from nsec?");
|
|
3640
|
+
}
|
|
3641
|
+
return new MinaSigner(this.identity.mina.privateKey);
|
|
3642
|
+
}
|
|
3643
|
+
// --- Backup ---
|
|
3644
|
+
/**
|
|
3645
|
+
* Publish the current vault to configured relays as a kind:30078 event.
|
|
3646
|
+
*/
|
|
3647
|
+
async backupToRelay() {
|
|
3648
|
+
if (!this.identity || !this.vault) {
|
|
3649
|
+
throw new Error("No active identity or vault to backup");
|
|
3650
|
+
}
|
|
3651
|
+
const eventTemplate = buildBackupEvent(
|
|
3652
|
+
this.vault,
|
|
3653
|
+
this.identity.nostr.secretKey
|
|
3654
|
+
);
|
|
3655
|
+
const signedEvent = finalizeEvent(
|
|
3656
|
+
eventTemplate,
|
|
3657
|
+
this.identity.nostr.secretKey
|
|
3658
|
+
);
|
|
3659
|
+
await publishBackupToRelays(signedEvent, this.config.relayUrls);
|
|
3660
|
+
}
|
|
3661
|
+
// --- Lock/Unlock ---
|
|
3662
|
+
/**
|
|
3663
|
+
* Clear keys from memory. The vault remains in IndexedDB.
|
|
3664
|
+
* Note: JavaScript strings (mnemonics) cannot be zeroed — only Uint8Array keys are cleared.
|
|
3665
|
+
*/
|
|
3666
|
+
lock() {
|
|
3667
|
+
if (this.identity) {
|
|
3668
|
+
this.identity.nostr.secretKey.fill(0);
|
|
3669
|
+
this.identity.evm.privateKey.fill(0);
|
|
3670
|
+
this.identity.solana.secretKey.fill(0);
|
|
3671
|
+
}
|
|
3672
|
+
this.identity = null;
|
|
3673
|
+
}
|
|
3674
|
+
/**
|
|
3675
|
+
* Re-assert Passkey to decrypt local vault and restore identity.
|
|
3676
|
+
* Uses the local vault's stored PRF salt for a single biometric prompt.
|
|
3677
|
+
*/
|
|
3678
|
+
async unlock() {
|
|
3679
|
+
const vault = await this.loadFromLocalStorage();
|
|
3680
|
+
if (!vault) {
|
|
3681
|
+
throw new Error("No local vault found \u2014 use create() or recover()");
|
|
3682
|
+
}
|
|
3683
|
+
return this.unlockWithVault(vault);
|
|
3684
|
+
}
|
|
3685
|
+
// --- Private helpers ---
|
|
3686
|
+
/**
|
|
3687
|
+
* Unlock a vault with a single Passkey assertion using stored PRF salts.
|
|
3688
|
+
* If the vault has only one credential, uses allowCredentials to constrain.
|
|
3689
|
+
*/
|
|
3690
|
+
async unlockWithVault(vault) {
|
|
3691
|
+
const firstEntry = vault.wrappedKeys[0];
|
|
3692
|
+
if (!firstEntry) {
|
|
3693
|
+
throw new Error("Vault has no registered credentials");
|
|
3694
|
+
}
|
|
3695
|
+
const assertion = await assertPasskey({
|
|
3696
|
+
rpId: this.config.rpId,
|
|
3697
|
+
prfSalt: fromBase642(firstEntry.salt)
|
|
3698
|
+
});
|
|
3699
|
+
const credIdHash = await hashCredentialId(assertion.credentialId);
|
|
3700
|
+
const matchingEntry = vault.wrappedKeys.find((e) => e.id === credIdHash);
|
|
3701
|
+
if (!matchingEntry) {
|
|
3702
|
+
throw new Error("This Passkey is not registered with the local vault");
|
|
3703
|
+
}
|
|
3704
|
+
let prfOutput = assertion.prfOutput;
|
|
3705
|
+
if (matchingEntry.id !== firstEntry.id) {
|
|
3706
|
+
const correctSalt = fromBase642(matchingEntry.salt);
|
|
3707
|
+
const reassertion = await assertPasskey({
|
|
3708
|
+
rpId: this.config.rpId,
|
|
3709
|
+
prfSalt: correctSalt,
|
|
3710
|
+
allowCredentials: [assertion.credentialId]
|
|
3711
|
+
});
|
|
3712
|
+
prfOutput = reassertion.prfOutput;
|
|
3713
|
+
}
|
|
3714
|
+
const kek = await deriveKek(prfOutput);
|
|
3715
|
+
const mnemonic = await unlockVault(vault, kek, credIdHash);
|
|
3716
|
+
const identity = await deriveFullIdentity(mnemonic);
|
|
3717
|
+
this.vault = vault;
|
|
3718
|
+
this.identity = identity;
|
|
3719
|
+
this.activeCredentialIdHash = credIdHash;
|
|
3720
|
+
return identity;
|
|
3721
|
+
}
|
|
3722
|
+
// --- IndexedDB Persistence ---
|
|
3723
|
+
async saveToLocalStorage() {
|
|
3724
|
+
if (!this.vault) return;
|
|
3725
|
+
if (typeof indexedDB === "undefined") return;
|
|
3726
|
+
const dbName = this.config.storageKey;
|
|
3727
|
+
const db = await openDb(dbName);
|
|
3728
|
+
const tx = db.transaction("vault", "readwrite");
|
|
3729
|
+
const store = tx.objectStore("vault");
|
|
3730
|
+
store.put(JSON.stringify(this.vault), "current");
|
|
3731
|
+
await new Promise((resolve, reject) => {
|
|
3732
|
+
tx.oncomplete = () => resolve();
|
|
3733
|
+
tx.onerror = () => reject(tx.error);
|
|
3734
|
+
});
|
|
3735
|
+
db.close();
|
|
3736
|
+
}
|
|
3737
|
+
async loadFromLocalStorage() {
|
|
3738
|
+
if (typeof indexedDB === "undefined") return null;
|
|
3739
|
+
const dbName = this.config.storageKey;
|
|
3740
|
+
try {
|
|
3741
|
+
const db = await openDb(dbName);
|
|
3742
|
+
const tx = db.transaction("vault", "readonly");
|
|
3743
|
+
const store = tx.objectStore("vault");
|
|
3744
|
+
const request = store.get("current");
|
|
3745
|
+
const result = await new Promise(
|
|
3746
|
+
(resolve, reject) => {
|
|
3747
|
+
request.onsuccess = () => resolve(request.result);
|
|
3748
|
+
request.onerror = () => reject(request.error);
|
|
3749
|
+
}
|
|
3750
|
+
);
|
|
3751
|
+
db.close();
|
|
3752
|
+
if (!result) return null;
|
|
3753
|
+
return JSON.parse(result);
|
|
3754
|
+
} catch {
|
|
3755
|
+
return null;
|
|
3756
|
+
}
|
|
3757
|
+
}
|
|
3758
|
+
};
|
|
3759
|
+
function openDb(name) {
|
|
3760
|
+
return new Promise((resolve, reject) => {
|
|
3761
|
+
const request = indexedDB.open(name, 1);
|
|
3762
|
+
request.onupgradeneeded = () => {
|
|
3763
|
+
const db = request.result;
|
|
3764
|
+
if (!db.objectStoreNames.contains("vault")) {
|
|
3765
|
+
db.createObjectStore("vault");
|
|
3766
|
+
}
|
|
3767
|
+
};
|
|
3768
|
+
request.onsuccess = () => resolve(request.result);
|
|
3769
|
+
request.onerror = () => reject(request.error);
|
|
3770
|
+
});
|
|
3771
|
+
}
|
|
1801
3772
|
export {
|
|
1802
3773
|
BtpRuntimeClient,
|
|
1803
3774
|
ChannelManager,
|
|
@@ -1805,14 +3776,24 @@ export {
|
|
|
1805
3776
|
EvmSigner,
|
|
1806
3777
|
HttpConnectorAdmin,
|
|
1807
3778
|
HttpRuntimeClient,
|
|
3779
|
+
KeyManager,
|
|
1808
3780
|
NetworkError,
|
|
1809
3781
|
OnChainChannelClient,
|
|
1810
3782
|
ToonClient,
|
|
1811
3783
|
ToonClientError,
|
|
1812
3784
|
ValidationError,
|
|
1813
3785
|
applyDefaults,
|
|
3786
|
+
buildBackupEvent,
|
|
3787
|
+
buildBackupFilter,
|
|
1814
3788
|
buildSettlementInfo,
|
|
3789
|
+
deriveFromNsec,
|
|
3790
|
+
deriveFullIdentity,
|
|
3791
|
+
generateMnemonic,
|
|
3792
|
+
generateRandomIdentity,
|
|
3793
|
+
isPrfSupported,
|
|
3794
|
+
parseBackupPayload,
|
|
1815
3795
|
validateConfig,
|
|
3796
|
+
validateMnemonic,
|
|
1816
3797
|
withRetry
|
|
1817
3798
|
};
|
|
1818
3799
|
//# sourceMappingURL=index.js.map
|