Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,28 @@
# Changelog

## 0.1.8 (2026-09-23)

### Fixed

- `getInfo()` accepts a 33-byte compressed Lightning node id (66 hex
characters, starting 02 or 03) as `pubkey`. It previously demanded 32-byte
hex and so refused the node id that LND-backed services and Alby Hub return.
- The demo no longer labels a failed payment after publication as "rejected by
nwc-kit". A timeout or invalid response on `pay_invoice` now reads "outcome
unknown: reconcile before retrying".

### Documented

- Classify `WALLET_ERROR`: only an explicit refusal such as `PAYMENT_FAILED` or
`INSUFFICIENT_BALANCE` is definite; `INTERNAL`, `OTHER` and unrecognised codes
are ambiguous. `UNSUPPORTED_EXTENSION` joins the pre-publication list.
- The wallet table separates Rizful, tested on mainnet, from wallets that were
only surveyed in code. `llms.txt` records the Rizful acceptance.
- The site no longer claims the result method is checked before decryption, or
that `inspectNwcConnection()` returns budget hints. Its version is injected
at build time and the LNURLvault installer link points at
`vault.lnurlcash.com`.

## 0.1.7 (2026-08-14)

### Documented
Expand Down
31 changes: 22 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ Convert sats explicitly at the application boundary and reject unsafe or
ambiguous amounts before making a wallet request.

Once a `payInvoice` request has been published, **every** failure is an unknown
payment outcome. That includes `RESPONSE_TIMEOUT`, `REQUEST_ABORTED`,
payment outcome unless the wallet explicitly refused it (see `WALLET_ERROR`
below). That includes `RESPONSE_TIMEOUT`, `REQUEST_ABORTED`,
`PUBLISH_FAILED`, `CLIENT_CLOSED` and, importantly, `INVALID_RESPONSE`.

`INVALID_RESPONSE` is the one that surprises people. It means the wallet replied
Expand All @@ -55,9 +56,18 @@ telling you nothing you can rely on. This is not hypothetical: a real bridge
observed during testing returned an empty preimage as a *successful* result when
its node could not route the payment.

Only failures raised **before** publication are safe to treat as definitely not
paid: `INVALID_CONNECTION`, `INVALID_REQUEST`, `UNSUPPORTED_METHOD`,
`UNSUPPORTED_ENCRYPTION` and `INFO_UNAVAILABLE`. Those never reach the wallet.
`WALLET_ERROR` means the wallet sent a signed, authenticated error, and its own
code is in `error.walletCode`. Only an explicit refusal is definite:
`PAYMENT_FAILED`, `INSUFFICIENT_BALANCE`, `QUOTA_EXCEEDED`, `RATE_LIMITED`,
`RESTRICTED`, `UNAUTHORIZED` or `NOT_IMPLEMENTED`. `INTERNAL` and `OTHER` are
ambiguous: the wallet hit a problem, not necessarily before it attempted the
payment. Treat any code outside that list the same way, including a missing one,
which the library reports as `OTHER`. Reconcile before retrying.

Apart from an explicit refusal, only failures raised **before** publication are
safe to treat as definitely not paid: `INVALID_CONNECTION`, `INVALID_REQUEST`, `UNSUPPORTED_METHOD`,
`UNSUPPORTED_ENCRYPTION`, `UNSUPPORTED_EXTENSION` and `INFO_UNAVAILABLE`. Those
never reach the wallet.

A relay can also store an event without returning a usable acknowledgement, so
even `PUBLISH_FAILED` does not prove the wallet never saw the request. Reconcile
Expand Down Expand Up @@ -138,14 +148,17 @@ replaceable kind 13194 event whose content lists the supported methods, tagged

### Known wallet behaviour

Surveyed by reading what each wallet's service code actually puts on the wire,
rather than what the specification says it should.
Only Rizful has been tested live. The others were surveyed by reading what each
wallet's service code actually puts on the wire, rather than what the
specification says it should, and are expected to work but have not been run
against.

| Wallet | Status | Note |
| --- | --- | --- |
| Alby Hub | Works | Unset fields arrive as `""` and `null`; handled since 0.1.4 |
| Coinos | Works | Advertises `nip44_v2`, omits `error` on success |
| Zeus | Works | Via `@getalby/sdk` |
| Rizful | Tested (mainnet) | Controlled acceptance through the 402-mcp payment path |
| Alby Hub | Expected (code survey) | Unset fields arrive as `""` and `null`; handled since 0.1.4 |
| Coinos | Expected (code survey) | Advertises `nip44_v2`, omits `error` on success |
| Zeus | Expected (code survey) | Via `@getalby/sdk` |
| LNbits `nwcprovider` | Not yet | NIP-04 only today. [PR #51](https://github.com/lnbits/nwcprovider/pull/51) adds NIP-44 v2 and would make it work |

No surveyed wallet publishes an `extensions` tag, so a method named in the
Expand Down
15 changes: 10 additions & 5 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ cryptographic, secret-handling or relay-authentication vulnerability.
and zeroises the library-owned secret and conversation-key byte arrays.
- Applications remain responsible for amount policy, user approval, wallet-side
budgets, BOLT-11 verification and preimage verification.
- Once a payment request has been published, every failure is an ambiguous
outcome: `RESPONSE_TIMEOUT`, `REQUEST_ABORTED`, `PUBLISH_FAILED`,
- Once a payment request has been published, every failure short of an explicit
wallet refusal is an ambiguous outcome: `RESPONSE_TIMEOUT`, `REQUEST_ABORTED`, `PUBLISH_FAILED`,
`CLIENT_CLOSED` and `INVALID_RESPONSE` alike. A relay can store an event
without returning a usable acknowledgement. Applications must reconcile the
invoice before retrying and must never read any of these as proof of
Expand All @@ -31,9 +31,14 @@ cryptographic, secret-handling or relay-authentication vulnerability.
route the payment. The library rejects such a response rather than reporting a
settlement it cannot substantiate, but rejecting it is not the same as knowing
the money stayed put.
- Only failures raised before publication are safe to treat as definitely not
paid: `INVALID_CONNECTION`, `INVALID_REQUEST`, `UNSUPPORTED_METHOD`,
`UNSUPPORTED_ENCRYPTION` and `INFO_UNAVAILABLE`.
- `WALLET_ERROR` carries the wallet's signed error code in `walletCode`. Only
an explicit refusal is definite: `PAYMENT_FAILED`, `INSUFFICIENT_BALANCE`,
`QUOTA_EXCEEDED`, `RATE_LIMITED`, `RESTRICTED`, `UNAUTHORIZED` or
`NOT_IMPLEMENTED`. `INTERNAL`, `OTHER` and any unrecognised or missing code
(reported as `OTHER`) are ambiguous and must be reconciled before retrying.
- Apart from an explicit refusal, only failures raised before publication are
safe to treat as definitely not paid: `INVALID_CONNECTION`, `INVALID_REQUEST`, `UNSUPPORTED_METHOD`,
`UNSUPPORTED_ENCRYPTION`, `UNSUPPORTED_EXTENSION` and `INFO_UNAVAILABLE`.

Passing tests is not evidence that money settled. Production acceptance requires
a real wallet response and a preimage that hashes to the invoice payment hash.
14 changes: 7 additions & 7 deletions docs/demo.js

Large diffs are not rendered by default.

20 changes: 12 additions & 8 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ <h1 class="reveal r2">Give your app a wallet connection,<br>not the <em>wallet i
<p class="hero-meta reveal r5">
One runtime dependency &nbsp;·&nbsp; Node&nbsp;22+, browsers, Deno, Bun
&nbsp;·&nbsp; NIP&#8209;47 / NIP&#8209;44&nbsp;v2 &nbsp;·&nbsp; MIT
&nbsp;·&nbsp; <span class="flag">v0.1.7, API not frozen</span>
&nbsp;·&nbsp; <span class="flag">v0.1.8, API not frozen</span>
</p>
</div>

Expand Down Expand Up @@ -104,8 +104,9 @@ <h1 class="reveal r2">Give your app a wallet connection,<br>not the <em>wallet i
<figcaption id="fig1-caption">
<span class="fig-no">Fig. 1</span>. <b>nwc-kit is the protocol layer, not the wallet.</b>
Your app gets an authenticated, NIP&#8209;44-encrypted connection over a relay it is
never asked to trust; the wallet keeps its keys and funds. Every reply is checked —
author, request reference, recipient and method — before a single byte is decrypted.
never asked to trust; the wallet keeps its keys and funds. Every reply's author,
request reference and recipient are checked before a single byte is decrypted, and
its result type before the result is read.
</figcaption>
</figure>

Expand Down Expand Up @@ -188,10 +189,13 @@ <h2>What it refuses to trust</h2>
<div class="spec-row">
<dt>Silence</dt>
<dd>
Once publication begins, timeout, abort, close and publish failure are ambiguous
outcomes. A relay can store an event without returning a usable acknowledgement.
The library says so, the types say so, and the docs say so, because a blind retry
can pay twice.
Once publication begins, timeout, abort, close, publish failure and an invalid
response are all ambiguous outcomes. A relay can store an event without returning
a usable acknowledgement, and a wallet can answer with a success it cannot
substantiate. A wallet error is definite only when it is an explicit refusal such
as <code>PAYMENT_FAILED</code> or <code>INSUFFICIENT_BALANCE</code>;
<code>INTERNAL</code> and <code>OTHER</code> are not. Reconcile before retrying,
because a blind retry can pay twice.
</dd>
</div>
</dl>
Expand Down Expand Up @@ -385,7 +389,7 @@ <h2>The whole API</h2>
</div>
<div class="spec-row">
<dt><code>inspectNwcConnection()</code></dt>
<dd>Reads a connection URI — relays, wallet pubkey, budget hints — without exposing
<dd>Reads a connection URI (relays, wallet pubkey and any lud16) without exposing
the secret.</dd>
</div>
</dl>
Expand Down
2 changes: 1 addition & 1 deletion docs/lnurlcash.html
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,7 @@ <h2>Try the pieces</h2>
before plaintext export. Check the current hardware status and security model before
flashing it.
<span class="doc-links">
<a href="https://dni.github.io/lnurl-vault/">Open the vault installer ↗</a>
<a href="https://vault.lnurlcash.com/">Open the vault installer ↗</a>
<a href="https://github.com/dni/lnurl-vault">Source</a>
</span>
</dd>
Expand Down
8 changes: 8 additions & 0 deletions docs/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -787,6 +787,7 @@ h3 { font-weight: 520; font-variation-settings: 'opsz' 32; }
}
#demo-status[data-kind='busy'] { color: var(--nostr); }
#demo-status[data-kind='ok'] { color: var(--ok); }
#demo-status[data-kind='warn'] { color: var(--warn); }
#demo-status[data-kind='error'] { color: var(--bad); }

.tape {
Expand Down Expand Up @@ -841,6 +842,13 @@ h3 { font-weight: 520; font-variation-settings: 'opsz' 32; }
border-color: color-mix(in srgb, var(--bad) 30%, transparent);
color: var(--bad);
}
.tape-unknown { border-left-color: var(--warn); }
.tape-unknown .tape-marker, .tape-unknown .tape-title { color: var(--warn); }
.tape-unknown .tape-detail {
background: var(--warn-bg);
border-color: color-mix(in srgb, var(--warn) 30%, transparent);
color: var(--warn);
}

/* ── recipe cards ─────────────────────────────────────────── */

Expand Down
7 changes: 4 additions & 3 deletions llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Live demo and documentation: https://nwc-kit.forgesworn.dev
GitHub: https://github.com/forgesworn/nwc-kit
npm: https://www.npmjs.com/package/@forgesworn/nwc-kit

**Early 0.x release.** Verified against the NIP-44 protocol vectors and an adversarial fake wallet, but not yet against a real wallet service in the field. The API may move within 0.x; pin an exact version.
**Early 0.x release.** Verified against the NIP-44 protocol vectors, an adversarial fake wallet, and a controlled mainnet acceptance through Rizful in the 402-mcp payment path. That proves one wallet and provider path, not every real-world implementation. The API may move within 0.x; pin an exact version.

ESM-only. Node 22+, browsers, Deno and Bun. No `node:` imports. One runtime dependency: `nostr-tools`. Two subpath exports: `@forgesworn/nwc-kit` (main), `@forgesworn/nwc-kit/extensions/05` (transaction history).

Expand Down Expand Up @@ -89,9 +89,10 @@ try {
- A valid wallet event that does not reference the active request is treated as a possible relay replay and ignored.
- NIP-44 v2 is required. Legacy NIP-04 is refused.
- The NWC connection URI is a spending capability. The library never logs or returns it. `close()` zeroises the library-owned secret and conversation-key byte arrays; JavaScript strings cannot be erased, so the caller still owns the URI string.
- Once a payment request is published, **every** failure is an ambiguous outcome, never proof of non-payment: `RESPONSE_TIMEOUT`, `REQUEST_ABORTED`, `PUBLISH_FAILED`, `CLIENT_CLOSED` and `INVALID_RESPONSE`. Reconcile the invoice before retrying; a blind retry can pay twice.
- Once a payment request is published, **every** failure short of an explicit wallet refusal is an ambiguous outcome, never proof of non-payment: `RESPONSE_TIMEOUT`, `REQUEST_ABORTED`, `PUBLISH_FAILED`, `CLIENT_CLOSED` and `INVALID_RESPONSE`. Reconcile the invoice before retrying; a blind retry can pay twice.
- `INVALID_RESPONSE` on a payment looks like a refusal and is not one. It means the wallet answered claiming a result that could not be trusted (bad preimage, wrong `result_type`, undecryptable payload). A real bridge returned an empty preimage as a *successful* result when its node could not route the payment. Rejecting that response is a statement about the evidence, not about the money.
- Only failures raised **before** publication prove nothing was attempted: `INVALID_CONNECTION`, `INVALID_REQUEST`, `UNSUPPORTED_METHOD`, `UNSUPPORTED_ENCRYPTION`, `INFO_UNAVAILABLE`.
- `WALLET_ERROR` carries the wallet's own code in `walletCode`. Only an explicit refusal is definite: `PAYMENT_FAILED`, `INSUFFICIENT_BALANCE`, `QUOTA_EXCEEDED`, `RATE_LIMITED`, `RESTRICTED`, `UNAUTHORIZED`, `NOT_IMPLEMENTED`. `INTERNAL`, `OTHER` and any unrecognised or missing code (reported as `OTHER`) are ambiguous: reconcile before retrying.
- Apart from an explicit refusal, only failures raised **before** publication prove nothing was attempted: `INVALID_CONNECTION`, `INVALID_REQUEST`, `UNSUPPORTED_METHOD`, `UNSUPPORTED_ENCRYPTION`, `UNSUPPORTED_EXTENSION`, `INFO_UNAVAILABLE`.
- Passing tests are not evidence that money settled. Verify the invoice before paying and verify the returned preimage against the invoice payment hash afterwards — `farrier-kit` does both.

## Not In Scope
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@forgesworn/nwc-kit",
"version": "0.1.7",
"version": "0.1.8",
"description": "Small, security-focused Nostr Wallet Connect client for TypeScript",
"author": "ForgeSworn",
"license": "MIT",
Expand Down
16 changes: 14 additions & 2 deletions scripts/build-site.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,18 @@ const result = await build({
metafile: true,
})

// The version shown on the page comes from package.json at build time, so a
// release cannot leave the site advertising the previous one.
const { version } = JSON.parse(await readFile(resolve(root, 'package.json'), 'utf8'))
const VERSION_PLACEHOLDER = '__NWC_KIT_VERSION__'

for (const file of ['index.html', 'lnurlcash.html', 'styles.css', 'favicon.svg']) {
await cp(resolve(site, file), resolve(out, file))
if (file.endsWith('.html')) {
const source = await readFile(resolve(site, file), 'utf8')
await writeFile(resolve(out, file), source.replaceAll(VERSION_PLACEHOLDER, version))
} else {
await cp(resolve(site, file), resolve(out, file))
}
}

// The three faces are served from this domain because the page's own CSP
Expand All @@ -43,6 +53,8 @@ await cp(resolve(site, 'fonts'), resolve(out, 'fonts'), { recursive: true })
await writeFile(resolve(out, '.nojekyll'), '')

const bytes = Object.values(result.metafile.outputs)[0].bytes
const html = await readFile(resolve(site, 'index.html'), 'utf8')
const html = await readFile(resolve(out, 'index.html'), 'utf8')
if (!html.includes('demo.js')) throw new Error('index.html does not load the demo bundle')
if (html.includes(VERSION_PLACEHOLDER)) throw new Error('index.html still carries the version placeholder')
if (!html.includes(`v${version},`)) throw new Error('index.html does not show the package version')
console.log(`site → docs/ (demo.js ${(bytes / 1024).toFixed(1)} KB)`)
17 changes: 12 additions & 5 deletions site/demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure
import * as nip44 from 'nostr-tools/nip44'
import { NwcClient, NwcError } from '../src/index.js'
import { NwcTransactionHistoryClient } from '../src/extensions/05.js'
import { classifyFailure } from './outcome.js'
import type {
NwcEvent,
NwcFilter,
Expand All @@ -18,7 +19,7 @@ import type {
NwcTransport,
} from '../src/types.js'

type LogKind = 'send' | 'recv' | 'ok' | 'reject' | 'info'
type LogKind = 'send' | 'recv' | 'ok' | 'reject' | 'unknown' | 'info'

function bytesToHex(bytes: Uint8Array): string {
let out = ''
Expand Down Expand Up @@ -61,7 +62,7 @@ function log(kind: LogKind, title: string, detail?: string): void {

const marker = document.createElement('span')
marker.className = 'tape-marker'
marker.textContent = { send: '→', recv: '←', ok: '✓', reject: '✗', info: '·' }[kind]
marker.textContent = { send: '→', recv: '←', ok: '✓', reject: '✗', unknown: '?', info: '·' }[kind]

const label = document.createElement('span')
label.className = 'tape-title'
Expand Down Expand Up @@ -325,7 +326,7 @@ const status = element<HTMLDivElement>('demo-status')
const balanceOut = element<HTMLSpanElement>('wallet-balance')
const scenarioSelect = element<HTMLSelectElement>('scenario')

function setStatus(text: string, kind: 'idle' | 'busy' | 'ok' | 'error' = 'idle'): void {
function setStatus(text: string, kind: 'idle' | 'busy' | 'ok' | 'warn' | 'error' = 'idle'): void {
status.textContent = text
status.dataset.kind = kind
}
Expand Down Expand Up @@ -355,8 +356,14 @@ async function run(label: string, action: (client: NwcTransactionHistoryClient)
setStatus(`${label} succeeded.`, 'ok')
} catch (error) {
const message = describeError(error)
log('reject', `${label} rejected by nwc-kit`, message)
setStatus(message, 'error')
const outcome = classifyFailure(label, error)
if (outcome.kind === 'unknown') {
log('unknown', outcome.title, message)
setStatus(`Outcome unknown: reconcile before retrying. ${message}`, 'warn')
} else {
log('reject', outcome.title, message)
setStatus(message, 'error')
}
} finally {
refreshBalance()
}
Expand Down
Loading