@breeztech/breez-sdk-spark 0.19.2 → 0.20.0-dev1

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.
@@ -0,0 +1,70 @@
1
+ 'use strict'
2
+
3
+ // Pure tests for the tolerant JSON evaluator; no network. Mirrors the unit
4
+ // tests in crates/breez-sdk/cli/tests/harness/assert.rs.
5
+
6
+ const assert = require('node:assert/strict')
7
+ const test = require('node:test')
8
+
9
+ const { checkMatcher, extractJsonDocs, interpolate, lookupPath } = require('./scenario')
10
+
11
+ test('extracts pretty and inline docs and skips noise', () => {
12
+ const chunk = 'Breez SDK: noise\n{\n "a": 1\n}\nError: nope\n{"b": 2}\nEvent: {"c": 3}\n'
13
+ assert.deepEqual(extractJsonDocs(chunk), [{ a: 1 }, { b: 2 }])
14
+ })
15
+
16
+ test('path matches across casings', () => {
17
+ const rust = { payment_request: { amount_msat: 5 } }
18
+ const wasm = { paymentRequest: { amountMsat: 5 } }
19
+ for (const doc of [rust, wasm]) {
20
+ assert.equal(lookupPath(doc, 'payment_request.amount_msat'), 5)
21
+ }
22
+ })
23
+
24
+ test('path bridges enum tags', () => {
25
+ const rust = { Bolt11Invoice: { amount_msat: 7 } }
26
+ const wasm = { type: 'bolt11Invoice', amountMsat: 7 }
27
+ for (const doc of [rust, wasm]) {
28
+ assert.equal(lookupPath(doc, 'bolt11_invoice.amount_msat'), 7)
29
+ }
30
+ })
31
+
32
+ test('empty path addresses the document', () => {
33
+ assert.deepEqual(lookupPath({}, ''), {})
34
+ checkMatcher({}, lookupPath({}, ''))
35
+ })
36
+
37
+ test('path indexes arrays', () => {
38
+ const doc = { payments: [{ id: 'x' }, { id: 'y' }] }
39
+ assert.equal(lookupPath(doc, 'payments.1.id'), 'y')
40
+ assert.equal(lookupPath(doc, 'payments.2.id'), undefined)
41
+ })
42
+
43
+ test('equality tolerates case and bigint strings', () => {
44
+ checkMatcher('completed', 'Completed')
45
+ checkMatcher('1000', 1000)
46
+ checkMatcher(1000, '1000')
47
+ assert.throws(() => checkMatcher('completed', 'failed'))
48
+ assert.throws(() => checkMatcher('completed', undefined))
49
+ })
50
+
51
+ test('gte accepts numbers and numeric strings', () => {
52
+ checkMatcher({ gte: 10 }, 11)
53
+ checkMatcher({ gte: 10 }, '10')
54
+ assert.throws(() => checkMatcher({ gte: 10 }, 9))
55
+ assert.throws(() => checkMatcher({ gte: 10 }, 'abc'))
56
+ assert.throws(() => checkMatcher({ gte: 10 }, undefined))
57
+ })
58
+
59
+ test('exists checks presence and null', () => {
60
+ checkMatcher({ exists: true }, 'x')
61
+ checkMatcher({ exists: false }, undefined)
62
+ checkMatcher({ exists: false }, null)
63
+ assert.throws(() => checkMatcher({ exists: true }, null))
64
+ assert.throws(() => checkMatcher({ exists: true }, undefined))
65
+ })
66
+
67
+ test('interpolation substitutes known vars and rejects unknown', () => {
68
+ assert.equal(interpolate('pay -r ${addr} -a 5', { addr: 'spark1x' }), 'pay -r spark1x -a 5')
69
+ assert.throws(() => interpolate('${missing}', {}))
70
+ })
@@ -0,0 +1,68 @@
1
+ 'use strict'
2
+
3
+ // Minimal client for the Lightspark regtest faucet, mirroring the Rust
4
+ // harness (crates/breez-sdk/cli/tests/harness/faucet.rs).
5
+
6
+ const DEFAULT_URL = 'https://api.lightspark.com/graphql/spark/rc'
7
+ const MAX_RETRIES = 3
8
+
9
+ /**
10
+ * Fund a regtest bitcoin address, returning the funding txid. Retries with
11
+ * exponential backoff. Reads FAUCET_URL, FAUCET_USERNAME, and
12
+ * FAUCET_PASSWORD from the environment.
13
+ *
14
+ * @param {string} address
15
+ * @param {number} amountSats
16
+ * @returns {Promise<string>}
17
+ */
18
+ async function fundAddress(address, amountSats) {
19
+ let lastError
20
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
21
+ if (attempt > 0) {
22
+ await new Promise((resolve) => setTimeout(resolve, 2 ** attempt * 1000))
23
+ }
24
+ try {
25
+ return await tryFundAddress(address, amountSats)
26
+ } catch (e) {
27
+ lastError = e
28
+ }
29
+ }
30
+ throw lastError
31
+ }
32
+
33
+ async function tryFundAddress(address, amountSats) {
34
+ const url = process.env.FAUCET_URL || DEFAULT_URL
35
+ const headers = { 'Content-Type': 'application/json' }
36
+ const { FAUCET_USERNAME, FAUCET_PASSWORD } = process.env
37
+ if (FAUCET_USERNAME && FAUCET_PASSWORD) {
38
+ const credentials = Buffer.from(`${FAUCET_USERNAME}:${FAUCET_PASSWORD}`).toString('base64')
39
+ headers.Authorization = `Basic ${credentials}`
40
+ }
41
+
42
+ const response = await fetch(url, {
43
+ method: 'POST',
44
+ headers,
45
+ body: JSON.stringify({
46
+ operationName: 'RequestRegtestFunds',
47
+ variables: { amount_sats: amountSats, address },
48
+ query:
49
+ 'mutation RequestRegtestFunds($address: String!, $amount_sats: Long!) { ' +
50
+ 'request_regtest_funds(input: {address: $address, amount_sats: $amount_sats}) ' +
51
+ '{ transaction_hash}}'
52
+ })
53
+ })
54
+ const body = await response.text()
55
+ if (!response.ok) {
56
+ throw new Error(`faucet request failed with status ${response.status}: ${body}`)
57
+ }
58
+ const parsed = JSON.parse(body)
59
+ if (parsed.errors && parsed.errors.length > 0) {
60
+ throw new Error(`faucet returned errors: ${parsed.errors.map((e) => e.message).join(', ')}`)
61
+ }
62
+ if (!parsed.data) {
63
+ throw new Error(`faucet response has no data: ${body}`)
64
+ }
65
+ return parsed.data.request_regtest_funds.transaction_hash
66
+ }
67
+
68
+ module.exports = { fundAddress }
@@ -0,0 +1,229 @@
1
+ 'use strict'
2
+
3
+ // LNURL server fixture driven through the docker CLI, mirroring the Rust
4
+ // harness (crates/breez-sdk/cli/tests/harness/lnurl.rs). Shares the
5
+ // breez-lnurl-built image tag: whichever runner builds it first wins. To
6
+ // force a rebuild: `docker image rm breez-lnurl-built:latest`.
7
+
8
+ const net = require('net')
9
+ const path = require('path')
10
+ const { execFile, execFileSync } = require('child_process')
11
+ const { promisify } = require('util')
12
+
13
+ const execFileAsync = promisify(execFile)
14
+
15
+ const IMAGE = 'breez-lnurl-built:latest'
16
+ const POSTGRES_IMAGE = 'postgres:16-alpine'
17
+ const HTTP_PORT = 8080
18
+ const POSTGRES_PORT = 5432
19
+ const START_TIMEOUT_MS = 120_000
20
+ const WORKSPACE_ROOT = path.join(__dirname, '../../../..')
21
+
22
+ /** @returns {boolean} whether the docker daemon is reachable */
23
+ function dockerAvailable() {
24
+ try {
25
+ execFileSync('docker', ['version'], { stdio: 'ignore' })
26
+ return true
27
+ } catch {
28
+ return false
29
+ }
30
+ }
31
+
32
+ async function docker(args, opts = {}) {
33
+ const { stdout } = await execFileAsync('docker', args, { maxBuffer: 64 * 1024 * 1024, ...opts })
34
+ return stdout
35
+ }
36
+
37
+ async function buildImageIfMissing() {
38
+ try {
39
+ await docker(['image', 'inspect', IMAGE])
40
+ return
41
+ } catch {
42
+ // Image missing: build it.
43
+ }
44
+ console.error(`building ${IMAGE} (first run only, this can take several minutes)`)
45
+ await docker([
46
+ 'build',
47
+ '-f',
48
+ path.join(WORKSPACE_ROOT, 'crates/breez-sdk/lnurl/Dockerfile'),
49
+ '-t',
50
+ IMAGE,
51
+ '--build-arg',
52
+ 'CARGO_FEATURES=dev',
53
+ WORKSPACE_ROOT
54
+ ])
55
+ }
56
+
57
+ /** @returns {Promise<number>} host port the container port was published to */
58
+ async function publishedPort(containerId, containerPort) {
59
+ const portOutput = await docker(['port', containerId, `${containerPort}/tcp`])
60
+ return Number(portOutput.split('\n')[0].trim().split(':').pop())
61
+ }
62
+
63
+ /**
64
+ * Resolves once something answers HTTP on `port`. Any status answers the
65
+ * question, so this only checks that a response came back. HTTP/1.0 so the
66
+ * server closes the connection when it is done.
67
+ */
68
+ function httpResponds(port) {
69
+ return new Promise((resolve, reject) => {
70
+ const socket = net.connect(port, '127.0.0.1')
71
+ let response = ''
72
+ socket.setTimeout(5000)
73
+ socket.on('connect', () => socket.write('GET /health HTTP/1.0\r\nHost: localhost\r\n\r\n'))
74
+ socket.on('data', (chunk) => {
75
+ response += chunk
76
+ })
77
+ socket.on('end', () =>
78
+ response.startsWith('HTTP/') ? resolve() : reject(new Error('no HTTP response'))
79
+ )
80
+ socket.on('timeout', () => socket.destroy(new Error('timed out')))
81
+ socket.on('error', reject)
82
+ })
83
+ }
84
+
85
+ class LnurlFixture {
86
+ /** @param {string} containerId @param {string} httpUrl @param {string} postgresContainerId */
87
+ constructor(containerId, httpUrl, postgresContainerId) {
88
+ this.containerId = containerId
89
+ this.httpUrl = httpUrl
90
+ // Backing database. Postgres is the server's only supported backend.
91
+ this.postgresContainerId = postgresContainerId
92
+ }
93
+
94
+ static async start() {
95
+ await buildImageIfMissing()
96
+ const fixture = new LnurlFixture('', '', '')
97
+ try {
98
+ const postgresOutput = await docker([
99
+ 'run',
100
+ '-d',
101
+ // All interfaces rather than 127.0.0.1: the lnurl server reaches this
102
+ // from inside its own container through the host gateway, which on
103
+ // Linux is the bridge address and not loopback.
104
+ '-p',
105
+ '0.0.0.0:0:5432',
106
+ '-e', 'POSTGRES_PASSWORD=postgres',
107
+ POSTGRES_IMAGE
108
+ ])
109
+ fixture.postgresContainerId = postgresOutput.trim()
110
+ await fixture.waitForPostgres()
111
+ const dbPort = await publishedPort(fixture.postgresContainerId, POSTGRES_PORT)
112
+
113
+ const runOutput = await docker([
114
+ 'run',
115
+ '-d',
116
+ '-p',
117
+ '127.0.0.1:0:8080',
118
+ '--add-host',
119
+ 'host.docker.internal:host-gateway',
120
+ '-e', 'BREEZ_LNURL_NETWORK=regtest',
121
+ '-e', 'BREEZ_LNURL_AUTO_MIGRATE=true',
122
+ '-e', `BREEZ_LNURL_DB_URL=postgres://postgres:postgres@host.docker.internal:${dbPort}/postgres`,
123
+ '-e', 'BREEZ_LNURL_LOG_LEVEL=lnurl=trace,info',
124
+ '-e', 'BREEZ_LNURL_DOMAINS=',
125
+ '-e', 'BREEZ_LNURL_SCHEME=http',
126
+ '-e', 'BREEZ_LNURL_NSEC=nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqsmhltgl',
127
+ '-e', 'BREEZ_LNURL_MIN_SENDABLE=1000',
128
+ '-e', 'BREEZ_LNURL_MAX_SENDABLE=1000000000',
129
+ '-e', 'BREEZ_LNURL_DEV_DONT_USE_LNURL_INCLUDE_SPARK_ADDRESS=false',
130
+ IMAGE
131
+ ])
132
+ fixture.containerId = runOutput.trim()
133
+
134
+ const port = await publishedPort(fixture.containerId, HTTP_PORT)
135
+ await fixture.waitForHttp(port)
136
+ fixture.httpUrl = `http://127.0.0.1:${port}`
137
+ return fixture
138
+ } catch (e) {
139
+ fixture.stop()
140
+ throw e
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Readiness cannot be taken from the logs: the server's startup line is
146
+ * printed before it migrates the database and binds, and it logs nothing
147
+ * afterwards. Nor is reaching the port enough, since docker's proxy accepts
148
+ * connections there from the moment the container starts and closes them
149
+ * again while nothing is listening inside.
150
+ */
151
+ async waitForHttp(port) {
152
+ const deadline = Date.now() + START_TIMEOUT_MS
153
+ for (;;) {
154
+ try {
155
+ await httpResponds(port)
156
+ return
157
+ } catch {
158
+ // Not serving yet.
159
+ }
160
+ await this.ensureRunning(this.containerId, 'lnurl server')
161
+ if (Date.now() >= deadline) {
162
+ const logs = await this.logs(this.containerId)
163
+ throw new Error(`lnurl server did not serve HTTP within ${START_TIMEOUT_MS}ms:\n${logs}`)
164
+ }
165
+ await new Promise((resolve) => setTimeout(resolve, 250))
166
+ }
167
+ }
168
+
169
+ async waitForPostgres() {
170
+ const deadline = Date.now() + START_TIMEOUT_MS
171
+ for (;;) {
172
+ try {
173
+ // Probe TCP explicitly: while initialising, the image's entrypoint
174
+ // runs a temporary socket-only server that the default `pg_isready`
175
+ // check would accept as ready.
176
+ await docker([
177
+ 'exec', this.postgresContainerId,
178
+ 'pg_isready', '-h', '127.0.0.1', '-U', 'postgres'
179
+ ])
180
+ return
181
+ } catch {
182
+ // Not accepting connections yet.
183
+ }
184
+ await this.ensureRunning(this.postgresContainerId, 'postgres')
185
+ if (Date.now() >= deadline) {
186
+ throw new Error(`postgres did not accept connections within ${START_TIMEOUT_MS}ms`)
187
+ }
188
+ await new Promise((resolve) => setTimeout(resolve, 250))
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Fail with the container's logs once it has exited. A container that dies
194
+ * on startup otherwise surfaces only as a readiness timeout, or as an
195
+ * unrelated docker error, neither of which names the real cause.
196
+ */
197
+ async ensureRunning(containerId, name) {
198
+ const state = await docker(['inspect', '-f', '{{.State.Running}}', containerId])
199
+ if (state.trim() === 'true') {
200
+ return
201
+ }
202
+ throw new Error(`${name} container exited before it was ready:\n${await this.logs(containerId)}`)
203
+ }
204
+
205
+ async logs(containerId) {
206
+ try {
207
+ // docker logs writes to both stdout and stderr; check both.
208
+ const { stdout, stderr } = await execFileAsync('docker', ['logs', containerId])
209
+ return stdout + stderr
210
+ } catch (e) {
211
+ return `failed to read container logs: ${e.message}`
212
+ }
213
+ }
214
+
215
+ stop() {
216
+ for (const containerId of [this.containerId, this.postgresContainerId]) {
217
+ if (!containerId) {
218
+ continue
219
+ }
220
+ try {
221
+ execFileSync('docker', ['rm', '-f', containerId], { stdio: 'ignore' })
222
+ } catch {
223
+ // Container may already be gone.
224
+ }
225
+ }
226
+ }
227
+ }
228
+
229
+ module.exports = { LnurlFixture, dockerAvailable }