@arkade-os/swap 0.0.1
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/README.md +562 -0
- package/dist/chunk-C5P7R7JT.js +1363 -0
- package/dist/index.cjs +3568 -0
- package/dist/index.d.cts +1747 -0
- package/dist/index.d.ts +1747 -0
- package/dist/index.js +2242 -0
- package/dist/nostr.cjs +223 -0
- package/dist/nostr.d.cts +99 -0
- package/dist/nostr.d.ts +99 -0
- package/dist/nostr.js +134 -0
- package/dist/rfq-CRgIOQ_y.d.cts +1219 -0
- package/dist/rfq-CRgIOQ_y.d.ts +1219 -0
- package/package.json +81 -0
package/dist/nostr.cjs
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/nostr.ts
|
|
31
|
+
var nostr_exports = {};
|
|
32
|
+
__export(nostr_exports, {
|
|
33
|
+
RFQ_AD_KIND: () => RFQ_AD_KIND,
|
|
34
|
+
RFQ_DIRECTED_KIND: () => RFQ_DIRECTED_KIND,
|
|
35
|
+
RelayUnavailable: () => RelayUnavailable,
|
|
36
|
+
nostrRfqTransport: () => nostrRfqTransport
|
|
37
|
+
});
|
|
38
|
+
module.exports = __toCommonJS(nostr_exports);
|
|
39
|
+
|
|
40
|
+
// src/rfq.ts
|
|
41
|
+
var import_base5 = require("@scure/base");
|
|
42
|
+
var import_legacy2 = require("@noble/hashes/legacy.js");
|
|
43
|
+
var import_sdk3 = require("@arkade-os/sdk");
|
|
44
|
+
|
|
45
|
+
// src/onchainHtlc.ts
|
|
46
|
+
var import_base = require("@scure/base");
|
|
47
|
+
var import_sha2 = require("@noble/hashes/sha2.js");
|
|
48
|
+
var import_legacy = require("@noble/hashes/legacy.js");
|
|
49
|
+
var btc = __toESM(require("@scure/btc-signer"), 1);
|
|
50
|
+
var ONCHAIN_ORDER_MARGIN_SECONDS = 2 * 60 * 60;
|
|
51
|
+
var ONCHAIN_CLAIM_MARGIN_SECONDS = 90 * 60;
|
|
52
|
+
var ONCHAIN_DUST_SATS = BigInt(330);
|
|
53
|
+
var L1_NETWORKS = {
|
|
54
|
+
bitcoin: btc.NETWORK,
|
|
55
|
+
testnet: btc.TEST_NETWORK,
|
|
56
|
+
regtest: { ...btc.TEST_NETWORK, bech32: "bcrt" }
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// src/secrets.ts
|
|
60
|
+
var import_base2 = require("@scure/base");
|
|
61
|
+
var import_sha22 = require("@noble/hashes/sha2.js");
|
|
62
|
+
var import_utils = require("@noble/hashes/utils.js");
|
|
63
|
+
var import_secp256k1 = require("@noble/curves/secp256k1.js");
|
|
64
|
+
var import_sdk = require("@arkade-os/sdk");
|
|
65
|
+
|
|
66
|
+
// src/claimPacket.ts
|
|
67
|
+
var import_base3 = require("@scure/base");
|
|
68
|
+
var import_secp256k12 = require("@noble/curves/secp256k1.js");
|
|
69
|
+
var import_hkdf = require("@noble/hashes/hkdf.js");
|
|
70
|
+
var import_sha23 = require("@noble/hashes/sha2.js");
|
|
71
|
+
var HKDF_INFO = new TextEncoder().encode("covclaimd/preimage/v1");
|
|
72
|
+
|
|
73
|
+
// src/lockupContract.ts
|
|
74
|
+
var import_base4 = require("@scure/base");
|
|
75
|
+
var import_sdk2 = require("@arkade-os/sdk");
|
|
76
|
+
|
|
77
|
+
// src/rfq.ts
|
|
78
|
+
var ARKADE_BTC = "arkade:BTC";
|
|
79
|
+
var LIGHTNING_BTC = "lightning:BTC";
|
|
80
|
+
var ONCHAIN_BTC = "onchain:BTC";
|
|
81
|
+
var rfqPair = (from, to) => `${from}->${to}`;
|
|
82
|
+
var LIGHTNING_SEND_PAIR = rfqPair(ARKADE_BTC, LIGHTNING_BTC);
|
|
83
|
+
var LIGHTNING_RECEIVE_PAIR = rfqPair(LIGHTNING_BTC, ARKADE_BTC);
|
|
84
|
+
var ONCHAIN_SEND_PAIR = rfqPair(ARKADE_BTC, ONCHAIN_BTC);
|
|
85
|
+
var ONCHAIN_RECEIVE_PAIR = rfqPair(ONCHAIN_BTC, ARKADE_BTC);
|
|
86
|
+
var SwapRefusal = class extends Error {
|
|
87
|
+
reason;
|
|
88
|
+
rfqId;
|
|
89
|
+
constructor(reason, rfqId) {
|
|
90
|
+
super(`solver refused: ${reason}`);
|
|
91
|
+
this.name = "SwapRefusal";
|
|
92
|
+
this.reason = reason;
|
|
93
|
+
this.rfqId = rfqId;
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
var MIN_HEADROOM_SECONDS = 90 * 60;
|
|
97
|
+
var MIN_CLAIM_WINDOW_SECONDS = 30 * 60;
|
|
98
|
+
|
|
99
|
+
// src/nostr.ts
|
|
100
|
+
var import_nostr_tools = require("nostr-tools");
|
|
101
|
+
var RFQ_DIRECTED_KIND = 24859;
|
|
102
|
+
var RFQ_AD_KIND = 38859;
|
|
103
|
+
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
104
|
+
var RelayUnavailable = class extends Error {
|
|
105
|
+
reasons;
|
|
106
|
+
constructor(reasons) {
|
|
107
|
+
super(`lost every relay connection: ${reasons.join("; ") || "connection closed"}`);
|
|
108
|
+
this.name = "RelayUnavailable";
|
|
109
|
+
this.reasons = reasons;
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
var closeReasons = (raw) => raw.map((entry) => {
|
|
113
|
+
if (typeof entry === "string") return entry;
|
|
114
|
+
const { url, reason } = entry ?? {};
|
|
115
|
+
return url ? `${url}: ${reason ?? "closed"}` : reason ?? "closed";
|
|
116
|
+
});
|
|
117
|
+
var asQuote = (payload, rfqId) => {
|
|
118
|
+
const p = payload;
|
|
119
|
+
if (p?.type === "rfq_refusal") throw new SwapRefusal(p.reason ?? "unknown", p.rfq_id ?? rfqId);
|
|
120
|
+
if (p?.type !== "rfq_quote" || p.rfq_id !== rfqId) {
|
|
121
|
+
throw new Error(`unexpected reply: ${p?.type ?? "no payload"}`);
|
|
122
|
+
}
|
|
123
|
+
return payload;
|
|
124
|
+
};
|
|
125
|
+
var nostrRfqTransport = (options) => {
|
|
126
|
+
const relays = options.relays;
|
|
127
|
+
const solverPubkey = options.solverPubkey;
|
|
128
|
+
const secretKey = options.secretKey ?? (0, import_nostr_tools.generateSecretKey)();
|
|
129
|
+
const pubkey = (0, import_nostr_tools.getPublicKey)(secretKey);
|
|
130
|
+
const pool = options.pool ?? new import_nostr_tools.SimplePool();
|
|
131
|
+
const ownsPool = !options.pool;
|
|
132
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
133
|
+
const conversationKey = import_nostr_tools.nip44.v2.utils.getConversationKey(secretKey, solverPubkey);
|
|
134
|
+
let closed = false;
|
|
135
|
+
const waiters = /* @__PURE__ */ new Map();
|
|
136
|
+
const subscription = pool.subscribeMany(
|
|
137
|
+
relays,
|
|
138
|
+
{ kinds: [RFQ_DIRECTED_KIND], "#p": [pubkey], authors: [solverPubkey] },
|
|
139
|
+
{
|
|
140
|
+
onevent(event) {
|
|
141
|
+
let payload;
|
|
142
|
+
try {
|
|
143
|
+
payload = JSON.parse(import_nostr_tools.nip44.v2.decrypt(event.content, conversationKey));
|
|
144
|
+
} catch {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const rfqId = payload?.rfq_id;
|
|
148
|
+
if (!rfqId) return;
|
|
149
|
+
waiters.get(rfqId)?.resolve(payload);
|
|
150
|
+
},
|
|
151
|
+
// subscribeMany calls this once every relay has closed. Nothing will
|
|
152
|
+
// arrive after it, so failing now beats waiting out the timeout — and
|
|
153
|
+
// it names the real cause instead of implicating the solver.
|
|
154
|
+
onclose(reasons) {
|
|
155
|
+
if (closed) return;
|
|
156
|
+
const error = new RelayUnavailable(closeReasons(reasons));
|
|
157
|
+
for (const waiter of waiters.values()) waiter.reject(error);
|
|
158
|
+
waiters.clear();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
);
|
|
162
|
+
const send = async (payload) => {
|
|
163
|
+
const event = (0, import_nostr_tools.finalizeEvent)(
|
|
164
|
+
{
|
|
165
|
+
kind: RFQ_DIRECTED_KIND,
|
|
166
|
+
created_at: Math.floor(Date.now() / 1e3),
|
|
167
|
+
tags: [["p", solverPubkey]],
|
|
168
|
+
content: import_nostr_tools.nip44.v2.encrypt(JSON.stringify(payload), conversationKey)
|
|
169
|
+
},
|
|
170
|
+
secretKey
|
|
171
|
+
);
|
|
172
|
+
const results = await Promise.allSettled(pool.publish(relays, event));
|
|
173
|
+
if (!results.some((r) => r.status === "fulfilled")) {
|
|
174
|
+
throw new Error("no relay accepted the RFQ message");
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
const awaitReply = (rfqId) => new Promise((resolve, reject) => {
|
|
178
|
+
const timer = setTimeout(() => {
|
|
179
|
+
waiters.delete(rfqId);
|
|
180
|
+
reject(new Error(`no solver reply within ${timeoutMs}ms`));
|
|
181
|
+
}, timeoutMs);
|
|
182
|
+
waiters.set(rfqId, {
|
|
183
|
+
resolve: (payload) => {
|
|
184
|
+
clearTimeout(timer);
|
|
185
|
+
waiters.delete(rfqId);
|
|
186
|
+
resolve(payload);
|
|
187
|
+
},
|
|
188
|
+
reject: (error) => {
|
|
189
|
+
clearTimeout(timer);
|
|
190
|
+
waiters.delete(rfqId);
|
|
191
|
+
reject(error);
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
return {
|
|
196
|
+
async requestQuote(payload) {
|
|
197
|
+
const rfqId = String(payload.rfq_id);
|
|
198
|
+
const reply = awaitReply(rfqId);
|
|
199
|
+
await send(payload);
|
|
200
|
+
return asQuote(await reply, rfqId);
|
|
201
|
+
},
|
|
202
|
+
async status(rfqId) {
|
|
203
|
+
const reply = awaitReply(rfqId);
|
|
204
|
+
await send({ v: 1, type: "rfq_status_request", rfq_id: rfqId });
|
|
205
|
+
const payload = await reply;
|
|
206
|
+
if (payload?.type !== "rfq_status") return null;
|
|
207
|
+
return payload;
|
|
208
|
+
},
|
|
209
|
+
async close() {
|
|
210
|
+
closed = true;
|
|
211
|
+
subscription.close();
|
|
212
|
+
waiters.clear();
|
|
213
|
+
if (ownsPool) pool.close(relays);
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
};
|
|
217
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
218
|
+
0 && (module.exports = {
|
|
219
|
+
RFQ_AD_KIND,
|
|
220
|
+
RFQ_DIRECTED_KIND,
|
|
221
|
+
RelayUnavailable,
|
|
222
|
+
nostrRfqTransport
|
|
223
|
+
});
|
package/dist/nostr.d.cts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { b as RfqTransport } from './rfq-CRgIOQ_y.cjs';
|
|
2
|
+
import { SimplePool } from 'nostr-tools';
|
|
3
|
+
import '@arkade-os/sdk';
|
|
4
|
+
import '@arkade-os/solver-discovery';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The Nostr RFQ transport — the PRODUCTION one (docs/rfq-protocol.md § 3.1 in
|
|
8
|
+
* arkade-os/lightning-swap-service).
|
|
9
|
+
*
|
|
10
|
+
* `rfq.ts` ships two other transports: `httpTransport`, and `relayTransport`
|
|
11
|
+
* which speaks the dev broker's `{op:"sub"|"event"}` framing. Neither is what a
|
|
12
|
+
* deployed solver listens on. This is, and it satisfies the same
|
|
13
|
+
* {@link RfqTransport} interface, so everything above the transport — the user
|
|
14
|
+
* flow, quote verification, the store — is identical whichever one it is handed.
|
|
15
|
+
*
|
|
16
|
+
* ## Why this is a separate entry point
|
|
17
|
+
*
|
|
18
|
+
* Imported as `@arkade-os/swap/nostr`, never from the package root, and
|
|
19
|
+
* `nostr-tools` is an OPTIONAL peer dependency. A consumer doing HTTP-only
|
|
20
|
+
* swaps should not pay for a Nostr library it never calls, and re-exporting
|
|
21
|
+
* this from `index.ts` would put `nostr-tools` in every consumer's module graph
|
|
22
|
+
* whether or not they resolve it. The subpath is what keeps that promise: the
|
|
23
|
+
* module is only loaded if it is imported.
|
|
24
|
+
*
|
|
25
|
+
* The consequence, stated plainly: importing this subpath without `nostr-tools`
|
|
26
|
+
* installed fails at resolution with a module-not-found. That is the intended
|
|
27
|
+
* failure — loud, at the import, naming the missing package — rather than a
|
|
28
|
+
* transport that silently degrades.
|
|
29
|
+
*
|
|
30
|
+
* A dynamic `await import()` inside the factory would also defer the cost, but
|
|
31
|
+
* it would make construction async. This transport opens its subscription
|
|
32
|
+
* eagerly, on purpose (see below), so an async factory would push a `await`
|
|
33
|
+
* into every caller for no benefit the subpath does not already give.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Directed RFQ traffic. Provisional in the spec; kept in one place.
|
|
38
|
+
*
|
|
39
|
+
* MUST stay inside NIP-01's ephemeral range (20000–29999) and MUST match the
|
|
40
|
+
* solver's `NOSTR_KIND_DIRECTED`. The two sides subscribe by `kinds`, so a
|
|
41
|
+
* mismatch is not an error either can report — they simply never see each
|
|
42
|
+
* other, and every request times out blaming the solver.
|
|
43
|
+
*
|
|
44
|
+
* Ephemeral because a negotiation is worthless once it is over: a request
|
|
45
|
+
* nobody answered inside the timeout, and a quote past its `valid_until`, help
|
|
46
|
+
* no one, while a retained copy is a permanent record of who negotiated what
|
|
47
|
+
* with whom. The cost is no store-and-forward — a request sent while the solver
|
|
48
|
+
* is disconnected is dropped rather than queued — which the timeout and the
|
|
49
|
+
* caller's retry cover.
|
|
50
|
+
*/
|
|
51
|
+
declare const RFQ_DIRECTED_KIND = 24859;
|
|
52
|
+
/**
|
|
53
|
+
* Indicative solver advertisement — never binding; only a quote binds.
|
|
54
|
+
*
|
|
55
|
+
* Addressable (30000–39999) rather than ephemeral, deliberately: an ad is
|
|
56
|
+
* standing state a relay should keep the current version of, not a message in a
|
|
57
|
+
* negotiation. The two constants want OPPOSITE retention, and neither is a typo
|
|
58
|
+
* of the other.
|
|
59
|
+
*/
|
|
60
|
+
declare const RFQ_AD_KIND = 38859;
|
|
61
|
+
/**
|
|
62
|
+
* Every relay dropped the subscription, so no reply can arrive on it.
|
|
63
|
+
*
|
|
64
|
+
* Distinct from a timeout on purpose. Both look like "no answer", but they mean
|
|
65
|
+
* opposite things: a timeout says the solver did not respond, while this says
|
|
66
|
+
* we were never in a position to hear it. Without the distinction the transport
|
|
67
|
+
* waits out the full timeout and then blames the solver for a failure on our
|
|
68
|
+
* own side of the wire.
|
|
69
|
+
*/
|
|
70
|
+
declare class RelayUnavailable extends Error {
|
|
71
|
+
readonly reasons: string[];
|
|
72
|
+
constructor(reasons: string[]);
|
|
73
|
+
}
|
|
74
|
+
interface NostrRfqOptions {
|
|
75
|
+
/** Relay URLs from the solver's card. The rendezvous, not solver endpoints. */
|
|
76
|
+
relays: string[];
|
|
77
|
+
/** The card's `discovery_pubkey`, x-only hex — who we address. */
|
|
78
|
+
solverPubkey: string;
|
|
79
|
+
/**
|
|
80
|
+
* Transport key. Defaults to a FRESH key per transport, deliberately: the
|
|
81
|
+
* negotiation should not be linkable to a long-term identity, and nothing in
|
|
82
|
+
* the protocol needs a stable client key — the quote binds to the covenant,
|
|
83
|
+
* not to who asked for it.
|
|
84
|
+
*/
|
|
85
|
+
secretKey?: Uint8Array;
|
|
86
|
+
/** Injectable for tests; a caller may also share one pool across swaps. */
|
|
87
|
+
pool?: SimplePool;
|
|
88
|
+
timeoutMs?: number;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Build an `RfqTransport` speaking kind-24859 directed traffic.
|
|
92
|
+
*
|
|
93
|
+
* Sends are fire-and-forget publishes; replies arrive on a single long-lived
|
|
94
|
+
* subscription filtered to this transport key, so a reply that arrives before
|
|
95
|
+
* the publish promise settles is not missed.
|
|
96
|
+
*/
|
|
97
|
+
declare const nostrRfqTransport: (options: NostrRfqOptions) => RfqTransport;
|
|
98
|
+
|
|
99
|
+
export { type NostrRfqOptions, RFQ_AD_KIND, RFQ_DIRECTED_KIND, RelayUnavailable, nostrRfqTransport };
|
package/dist/nostr.d.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { b as RfqTransport } from './rfq-CRgIOQ_y.js';
|
|
2
|
+
import { SimplePool } from 'nostr-tools';
|
|
3
|
+
import '@arkade-os/sdk';
|
|
4
|
+
import '@arkade-os/solver-discovery';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The Nostr RFQ transport — the PRODUCTION one (docs/rfq-protocol.md § 3.1 in
|
|
8
|
+
* arkade-os/lightning-swap-service).
|
|
9
|
+
*
|
|
10
|
+
* `rfq.ts` ships two other transports: `httpTransport`, and `relayTransport`
|
|
11
|
+
* which speaks the dev broker's `{op:"sub"|"event"}` framing. Neither is what a
|
|
12
|
+
* deployed solver listens on. This is, and it satisfies the same
|
|
13
|
+
* {@link RfqTransport} interface, so everything above the transport — the user
|
|
14
|
+
* flow, quote verification, the store — is identical whichever one it is handed.
|
|
15
|
+
*
|
|
16
|
+
* ## Why this is a separate entry point
|
|
17
|
+
*
|
|
18
|
+
* Imported as `@arkade-os/swap/nostr`, never from the package root, and
|
|
19
|
+
* `nostr-tools` is an OPTIONAL peer dependency. A consumer doing HTTP-only
|
|
20
|
+
* swaps should not pay for a Nostr library it never calls, and re-exporting
|
|
21
|
+
* this from `index.ts` would put `nostr-tools` in every consumer's module graph
|
|
22
|
+
* whether or not they resolve it. The subpath is what keeps that promise: the
|
|
23
|
+
* module is only loaded if it is imported.
|
|
24
|
+
*
|
|
25
|
+
* The consequence, stated plainly: importing this subpath without `nostr-tools`
|
|
26
|
+
* installed fails at resolution with a module-not-found. That is the intended
|
|
27
|
+
* failure — loud, at the import, naming the missing package — rather than a
|
|
28
|
+
* transport that silently degrades.
|
|
29
|
+
*
|
|
30
|
+
* A dynamic `await import()` inside the factory would also defer the cost, but
|
|
31
|
+
* it would make construction async. This transport opens its subscription
|
|
32
|
+
* eagerly, on purpose (see below), so an async factory would push a `await`
|
|
33
|
+
* into every caller for no benefit the subpath does not already give.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Directed RFQ traffic. Provisional in the spec; kept in one place.
|
|
38
|
+
*
|
|
39
|
+
* MUST stay inside NIP-01's ephemeral range (20000–29999) and MUST match the
|
|
40
|
+
* solver's `NOSTR_KIND_DIRECTED`. The two sides subscribe by `kinds`, so a
|
|
41
|
+
* mismatch is not an error either can report — they simply never see each
|
|
42
|
+
* other, and every request times out blaming the solver.
|
|
43
|
+
*
|
|
44
|
+
* Ephemeral because a negotiation is worthless once it is over: a request
|
|
45
|
+
* nobody answered inside the timeout, and a quote past its `valid_until`, help
|
|
46
|
+
* no one, while a retained copy is a permanent record of who negotiated what
|
|
47
|
+
* with whom. The cost is no store-and-forward — a request sent while the solver
|
|
48
|
+
* is disconnected is dropped rather than queued — which the timeout and the
|
|
49
|
+
* caller's retry cover.
|
|
50
|
+
*/
|
|
51
|
+
declare const RFQ_DIRECTED_KIND = 24859;
|
|
52
|
+
/**
|
|
53
|
+
* Indicative solver advertisement — never binding; only a quote binds.
|
|
54
|
+
*
|
|
55
|
+
* Addressable (30000–39999) rather than ephemeral, deliberately: an ad is
|
|
56
|
+
* standing state a relay should keep the current version of, not a message in a
|
|
57
|
+
* negotiation. The two constants want OPPOSITE retention, and neither is a typo
|
|
58
|
+
* of the other.
|
|
59
|
+
*/
|
|
60
|
+
declare const RFQ_AD_KIND = 38859;
|
|
61
|
+
/**
|
|
62
|
+
* Every relay dropped the subscription, so no reply can arrive on it.
|
|
63
|
+
*
|
|
64
|
+
* Distinct from a timeout on purpose. Both look like "no answer", but they mean
|
|
65
|
+
* opposite things: a timeout says the solver did not respond, while this says
|
|
66
|
+
* we were never in a position to hear it. Without the distinction the transport
|
|
67
|
+
* waits out the full timeout and then blames the solver for a failure on our
|
|
68
|
+
* own side of the wire.
|
|
69
|
+
*/
|
|
70
|
+
declare class RelayUnavailable extends Error {
|
|
71
|
+
readonly reasons: string[];
|
|
72
|
+
constructor(reasons: string[]);
|
|
73
|
+
}
|
|
74
|
+
interface NostrRfqOptions {
|
|
75
|
+
/** Relay URLs from the solver's card. The rendezvous, not solver endpoints. */
|
|
76
|
+
relays: string[];
|
|
77
|
+
/** The card's `discovery_pubkey`, x-only hex — who we address. */
|
|
78
|
+
solverPubkey: string;
|
|
79
|
+
/**
|
|
80
|
+
* Transport key. Defaults to a FRESH key per transport, deliberately: the
|
|
81
|
+
* negotiation should not be linkable to a long-term identity, and nothing in
|
|
82
|
+
* the protocol needs a stable client key — the quote binds to the covenant,
|
|
83
|
+
* not to who asked for it.
|
|
84
|
+
*/
|
|
85
|
+
secretKey?: Uint8Array;
|
|
86
|
+
/** Injectable for tests; a caller may also share one pool across swaps. */
|
|
87
|
+
pool?: SimplePool;
|
|
88
|
+
timeoutMs?: number;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Build an `RfqTransport` speaking kind-24859 directed traffic.
|
|
92
|
+
*
|
|
93
|
+
* Sends are fire-and-forget publishes; replies arrive on a single long-lived
|
|
94
|
+
* subscription filtered to this transport key, so a reply that arrives before
|
|
95
|
+
* the publish promise settles is not missed.
|
|
96
|
+
*/
|
|
97
|
+
declare const nostrRfqTransport: (options: NostrRfqOptions) => RfqTransport;
|
|
98
|
+
|
|
99
|
+
export { type NostrRfqOptions, RFQ_AD_KIND, RFQ_DIRECTED_KIND, RelayUnavailable, nostrRfqTransport };
|
package/dist/nostr.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SwapRefusal
|
|
3
|
+
} from "./chunk-C5P7R7JT.js";
|
|
4
|
+
|
|
5
|
+
// src/nostr.ts
|
|
6
|
+
import {
|
|
7
|
+
finalizeEvent,
|
|
8
|
+
generateSecretKey,
|
|
9
|
+
getPublicKey,
|
|
10
|
+
nip44,
|
|
11
|
+
SimplePool
|
|
12
|
+
} from "nostr-tools";
|
|
13
|
+
var RFQ_DIRECTED_KIND = 24859;
|
|
14
|
+
var RFQ_AD_KIND = 38859;
|
|
15
|
+
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
16
|
+
var RelayUnavailable = class extends Error {
|
|
17
|
+
reasons;
|
|
18
|
+
constructor(reasons) {
|
|
19
|
+
super(`lost every relay connection: ${reasons.join("; ") || "connection closed"}`);
|
|
20
|
+
this.name = "RelayUnavailable";
|
|
21
|
+
this.reasons = reasons;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
var closeReasons = (raw) => raw.map((entry) => {
|
|
25
|
+
if (typeof entry === "string") return entry;
|
|
26
|
+
const { url, reason } = entry ?? {};
|
|
27
|
+
return url ? `${url}: ${reason ?? "closed"}` : reason ?? "closed";
|
|
28
|
+
});
|
|
29
|
+
var asQuote = (payload, rfqId) => {
|
|
30
|
+
const p = payload;
|
|
31
|
+
if (p?.type === "rfq_refusal") throw new SwapRefusal(p.reason ?? "unknown", p.rfq_id ?? rfqId);
|
|
32
|
+
if (p?.type !== "rfq_quote" || p.rfq_id !== rfqId) {
|
|
33
|
+
throw new Error(`unexpected reply: ${p?.type ?? "no payload"}`);
|
|
34
|
+
}
|
|
35
|
+
return payload;
|
|
36
|
+
};
|
|
37
|
+
var nostrRfqTransport = (options) => {
|
|
38
|
+
const relays = options.relays;
|
|
39
|
+
const solverPubkey = options.solverPubkey;
|
|
40
|
+
const secretKey = options.secretKey ?? generateSecretKey();
|
|
41
|
+
const pubkey = getPublicKey(secretKey);
|
|
42
|
+
const pool = options.pool ?? new SimplePool();
|
|
43
|
+
const ownsPool = !options.pool;
|
|
44
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
45
|
+
const conversationKey = nip44.v2.utils.getConversationKey(secretKey, solverPubkey);
|
|
46
|
+
let closed = false;
|
|
47
|
+
const waiters = /* @__PURE__ */ new Map();
|
|
48
|
+
const subscription = pool.subscribeMany(
|
|
49
|
+
relays,
|
|
50
|
+
{ kinds: [RFQ_DIRECTED_KIND], "#p": [pubkey], authors: [solverPubkey] },
|
|
51
|
+
{
|
|
52
|
+
onevent(event) {
|
|
53
|
+
let payload;
|
|
54
|
+
try {
|
|
55
|
+
payload = JSON.parse(nip44.v2.decrypt(event.content, conversationKey));
|
|
56
|
+
} catch {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const rfqId = payload?.rfq_id;
|
|
60
|
+
if (!rfqId) return;
|
|
61
|
+
waiters.get(rfqId)?.resolve(payload);
|
|
62
|
+
},
|
|
63
|
+
// subscribeMany calls this once every relay has closed. Nothing will
|
|
64
|
+
// arrive after it, so failing now beats waiting out the timeout — and
|
|
65
|
+
// it names the real cause instead of implicating the solver.
|
|
66
|
+
onclose(reasons) {
|
|
67
|
+
if (closed) return;
|
|
68
|
+
const error = new RelayUnavailable(closeReasons(reasons));
|
|
69
|
+
for (const waiter of waiters.values()) waiter.reject(error);
|
|
70
|
+
waiters.clear();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
);
|
|
74
|
+
const send = async (payload) => {
|
|
75
|
+
const event = finalizeEvent(
|
|
76
|
+
{
|
|
77
|
+
kind: RFQ_DIRECTED_KIND,
|
|
78
|
+
created_at: Math.floor(Date.now() / 1e3),
|
|
79
|
+
tags: [["p", solverPubkey]],
|
|
80
|
+
content: nip44.v2.encrypt(JSON.stringify(payload), conversationKey)
|
|
81
|
+
},
|
|
82
|
+
secretKey
|
|
83
|
+
);
|
|
84
|
+
const results = await Promise.allSettled(pool.publish(relays, event));
|
|
85
|
+
if (!results.some((r) => r.status === "fulfilled")) {
|
|
86
|
+
throw new Error("no relay accepted the RFQ message");
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
const awaitReply = (rfqId) => new Promise((resolve, reject) => {
|
|
90
|
+
const timer = setTimeout(() => {
|
|
91
|
+
waiters.delete(rfqId);
|
|
92
|
+
reject(new Error(`no solver reply within ${timeoutMs}ms`));
|
|
93
|
+
}, timeoutMs);
|
|
94
|
+
waiters.set(rfqId, {
|
|
95
|
+
resolve: (payload) => {
|
|
96
|
+
clearTimeout(timer);
|
|
97
|
+
waiters.delete(rfqId);
|
|
98
|
+
resolve(payload);
|
|
99
|
+
},
|
|
100
|
+
reject: (error) => {
|
|
101
|
+
clearTimeout(timer);
|
|
102
|
+
waiters.delete(rfqId);
|
|
103
|
+
reject(error);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
return {
|
|
108
|
+
async requestQuote(payload) {
|
|
109
|
+
const rfqId = String(payload.rfq_id);
|
|
110
|
+
const reply = awaitReply(rfqId);
|
|
111
|
+
await send(payload);
|
|
112
|
+
return asQuote(await reply, rfqId);
|
|
113
|
+
},
|
|
114
|
+
async status(rfqId) {
|
|
115
|
+
const reply = awaitReply(rfqId);
|
|
116
|
+
await send({ v: 1, type: "rfq_status_request", rfq_id: rfqId });
|
|
117
|
+
const payload = await reply;
|
|
118
|
+
if (payload?.type !== "rfq_status") return null;
|
|
119
|
+
return payload;
|
|
120
|
+
},
|
|
121
|
+
async close() {
|
|
122
|
+
closed = true;
|
|
123
|
+
subscription.close();
|
|
124
|
+
waiters.clear();
|
|
125
|
+
if (ownsPool) pool.close(relays);
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
};
|
|
129
|
+
export {
|
|
130
|
+
RFQ_AD_KIND,
|
|
131
|
+
RFQ_DIRECTED_KIND,
|
|
132
|
+
RelayUnavailable,
|
|
133
|
+
nostrRfqTransport
|
|
134
|
+
};
|