Merge branch 'main' into feat/blobbi-1124-interactions

This commit is contained in:
filemon
2026-04-30 00:56:01 -03:00
39 changed files with 4423 additions and 49 deletions
+85
View File
@@ -0,0 +1,85 @@
# Bitcoin PSBT Signing for Nostr Signers
This document specifies how Nostr signers (NIP-07 browser extensions and NIP-46 remote signers) can support signing Bitcoin Partially Signed Bitcoin Transactions (PSBTs).
## Motivation
Nostr and Bitcoin Taproot (BIP-341) share identical cryptographic primitives: secp256k1 with 32-byte x-only public keys and BIP-340 Schnorr signatures. This means a Nostr private key can directly sign Bitcoin Taproot transactions without any key conversion. To enable this, Nostr signers need a method to sign PSBTs.
## `signPsbt` Method
### NIP-07 (Browser Extensions)
Extensions that support Bitcoin signing MUST expose a `signPsbt` method on the `window.nostr` object:
```typescript
window.nostr.signPsbt(psbtHex: string): Promise<string>
```
**Parameters:**
- `psbtHex` — Hex-encoded PSBT (BIP-174/BIP-370).
**Returns:**
- A hex-encoded PSBT with Taproot key-path signatures (`tapKeySig`) added to matching inputs.
### NIP-46 (Remote Signers)
Remote signers that support Bitcoin signing MUST handle the `sign_psbt` RPC method:
```
method: "sign_psbt"
params: ["<hex-encoded PSBT>"]
result: "<hex-encoded signed PSBT>"
```
The method follows the same NIP-46 request/response pattern as `sign_event`. If the signer does not support this method, it MUST return an error.
## Signer Behavior
When a signer receives a PSBT to sign, it MUST:
1. Decode the PSBT from hex.
2. For each input, check if `tapInternalKey` is present.
3. Compare the input's `tapInternalKey` against the signer's own 32-byte x-only public key.
4. For each matching input:
a. Compute the BIP-341 tweak: `t = taggedHash("TapTweak", tapInternalKey)`.
b. Tweak the private key: apply `t` to the secret key with y-parity correction (negate the key if the corresponding public key has an odd y-coordinate, then add the tweak scalar modulo the curve order).
c. Compute the BIP-341 sighash for the input.
d. Produce a BIP-340 Schnorr signature over the sighash using the tweaked key.
e. Set `tapKeySig` on the input.
5. Return the PSBT with signatures added. The signer MUST NOT finalize or extract the transaction.
Inputs whose `tapInternalKey` does not match the signer's key MUST be left unchanged.
## Security Considerations
- Signers SHOULD display a confirmation dialog showing the transaction outputs, amounts, and fees before signing.
- Signers SHOULD reject PSBTs that do not contain any inputs matching the signer's key.
- The PSBT format (BIP-174) carries all information needed for the signer to verify what is being signed, including input amounts (`witnessUtxo`) and output destinations.
## Capability Detection
### NIP-07
Clients SHOULD check for the presence of `signPsbt` before calling it:
```typescript
if (typeof window.nostr?.signPsbt === 'function') {
const signedHex = await window.nostr.signPsbt(unsignedPsbtHex);
}
```
### NIP-46
Clients SHOULD handle errors gracefully when the remote signer does not support `sign_psbt`. If the signer returns an error for an unknown method, the client should inform the user that their signer does not support Bitcoin signing.
## References
- [BIP-174: Partially Signed Bitcoin Transaction Format](https://github.com/bitcoin/bips/blob/master/bip-0174.mediawiki)
- [BIP-340: Schnorr Signatures for secp256k1](https://github.com/bitcoin/bips/blob/master/bip-0340.mediawiki)
- [BIP-341: Taproot (SegWit v1 Spending Rules)](https://github.com/bitcoin/bips/blob/master/bip-0341.mediawiki)
- [BIP-370: PSBT Version 2](https://github.com/bitcoin/bips/blob/master/bip-0370.mediawiki)
- [NIP-07: `window.nostr` capability for web browsers](https://github.com/nostr-protocol/nips/blob/master/07.md)
- [NIP-46: Nostr Remote Signing](https://github.com/nostr-protocol/nips/blob/master/46.md)
+96
View File
@@ -6,6 +6,7 @@
| Kind | Name | Description |
|-------|----------------------|-------------------------------------------------------|
| 8333 | Onchain Zap | Attestation that an on-chain BTC tx paid a target |
| 36767 | Theme Definition | Shareable, named custom UI theme |
| 16767 | Active Profile Theme | The user's currently active theme (one per user) |
| 16769 | Profile Tabs | The user's custom profile page tabs (one per user) |
@@ -35,6 +36,101 @@ These event kinds were created by community contributors and are supported by Di
---
## Kind 8333: Onchain Zap
### Summary
Regular event kind that records a **Bitcoin on-chain payment** ("onchain zap") sent in appreciation of a Nostr event or profile. Functions as the on-chain analogue of NIP-57 zap receipts (kind 9735), but without the LNURL round-trip: the event is self-attested by the sender and references a real Bitcoin transaction that clients can verify directly on-chain.
The kind number mirrors the convention of NIP-57: kind **9735** is the Lightning P2P port (per BOLT spec), and kind **8333** is the Bitcoin mainnet P2P port — a natural semantic pairing for Lightning vs. on-chain settlement.
Because every Nostr keypair deterministically maps to a Bitcoin Taproot (P2TR) address (both use 32-byte x-only secp256k1 keys, per BIP-340/BIP-341), an on-chain zap is simply a Bitcoin transaction whose output pays the recipient's derived Taproot address. The kind 8333 event links that transaction to the Nostr event or profile being zapped.
### Event Structure
```json
{
"kind": 8333,
"pubkey": "<sender-pubkey>",
"content": "Great post!",
"tags": [
["e", "<target-event-id>", "<relay-hint>"],
["p", "<target-pubkey>"],
["i", "bitcoin:tx:<txid>"],
["amount", "<sats>"],
["alt", "On-chain zap: 25000 sats"]
]
}
```
### Content
The `content` field is a human-readable comment from the sender (may be empty). It is NOT a zap request JSON (unlike NIP-57 kind 9735).
### Tags
| Tag | Required | Description |
|----------|----------|----------------------------------------------------------------------------------------------|
| `i` | Yes | NIP-73 external content identifier. MUST be `bitcoin:tx:<txid>` where `<txid>` is a 64-char lowercase hex Bitcoin transaction ID. |
| `p` | Yes | 32-byte hex pubkey of the zap **recipient** (the author being paid). |
| `amount` | Yes | Amount paid to the recipient in **satoshis** (decimal integer). This is the sum of outputs in the tx that paid the recipient's derived Taproot address — *not* the total tx value. |
| `e` | If zapping an event | 32-byte hex ID of the event being zapped. Include a relay hint as the 3rd element where possible. |
| `a` | If zapping an addressable event | Addressable event coordinate `<kind>:<pubkey>:<d-tag>`. Used instead of (or alongside) `e` for kinds 3000039999. |
| `alt` | Yes | NIP-31 human-readable fallback. |
If neither `e` nor `a` is present, the zap targets the recipient's **profile** (i.e. a tip to the pubkey, not to a specific event).
### Publishing Flow
1. Sender builds a Bitcoin transaction paying the recipient's derived Taproot address (`nostrPubkeyToBitcoinAddress(recipientPubkey)`).
2. Sender broadcasts the transaction to the Bitcoin network and obtains the `txid`.
3. Sender signs and publishes a kind 8333 event referencing that `txid` with the appropriate `e`/`a`/`p` tags.
4. The event is published **after** broadcast; the txid is already final at that point.
### Client Behavior
**Querying onchain zaps for an event:**
```json
{ "kinds": [8333], "#e": ["<target-event-id>"], "limit": 100 }
```
For addressable events, use `"#a": ["<kind>:<pubkey>:<d-tag>"]` instead. For profile-level zaps, use `"#p": ["<pubkey>"]`.
**Verification (REQUIRED before trusting amounts):**
Clients MUST verify a kind 8333 event on-chain before counting it toward a zap total or displaying its amount. The `amount` tag is self-reported by the sender and would otherwise be trivially spoofable. To verify:
1. Extract the txid from the `i` tag.
2. Fetch the transaction from a Bitcoin data source (e.g. a mempool.space-compatible Esplora API).
3. Derive the recipient's expected Taproot address from the `p` tag pubkey.
4. Sum the values of all outputs in the transaction that pay that address. This is the **verified amount**. Change outputs paying back to the **sender's** derived Taproot address MUST NOT be counted toward the verified amount — only outputs to the recipient.
5. If the verified amount is 0, the event SHOULD be discarded.
6. If the sender's `amount` tag exceeds the verified amount, clients MAY discard the event or MAY display the smaller verified amount (capping). Clients MUST NOT display or count the claimed amount when it exceeds the verified amount.
7. Unconfirmed transactions MAY be displayed as pending; clients MAY require confirmation before counting them toward public totals. Because unconfirmed transactions can be evicted (RBF, double-spend), clients SHOULD either exclude them from aggregate zap totals or clearly label them as pending.
**Sender/recipient identity:** Clients SHOULD reject events where the sender's pubkey (`event.pubkey`) equals the recipient pubkey from the `p` tag. Self-zaps are trivial to fabricate (the sender already controls the destination address) and contribute nothing meaningful to zap totals.
**Deduplication:** Clients SHOULD deduplicate events that reference the same `txid` (an attacker could publish many events pointing at one real transaction). One kind 8333 event per (txid, target) pair is canonical — when multiple events reference the same `txid` for the same target, the earliest is preferred.
**Network scope:** This specification applies to Bitcoin **mainnet** only. Testnet, signet, and other networks are out of scope; addresses and txids on those networks MUST NOT be used in kind 8333 events.
### Comparison with NIP-57 (Lightning Zaps)
| Aspect | NIP-57 (kind 9735) | This spec (kind 8333) |
|--------|---------------------|------------------------|
| Settlement | Lightning Network | Bitcoin L1 |
| Invoice / payment | LNURL + BOLT-11 invoice | Raw Bitcoin tx |
| Event issuer | Recipient's LNURL provider | Sender |
| Availability | Requires `lud06`/`lud16` on recipient profile | Always available (every Nostr pubkey has a derived Taproot addr) |
| Verification | Recipient zap-provider pubkey + bolt11 amount | On-chain tx verified against derived recipient address |
| Finality | Instant | Confirms in ~10 min (mempool first) |
| Fees | Sub-satoshi typical | Significant at low amounts |
The two zap kinds are complementary. Clients SHOULD sum verified amounts from both kinds when displaying total zap stats for a post or profile.
---
## Kind 36767: Theme Definition
### Summary
+213
View File
@@ -0,0 +1,213 @@
# Nostr-to-Bitcoin Wallet
This document explains how the application derives a Bitcoin Taproot address from a Nostr public key, enabling every Nostr identity to function as a Bitcoin wallet.
## Why This Works
Nostr and Bitcoin Taproot (BIP-341) share the exact same cryptographic primitives:
| Property | Nostr | Bitcoin Taproot |
|---|---|---|
| Curve | secp256k1 | secp256k1 |
| Signature scheme | Schnorr (BIP-340) | Schnorr (BIP-340) |
| Public key format | 32-byte x-only | 32-byte x-only |
Because the key formats are byte-for-byte identical, a Nostr public key can be used **directly** as a Taproot internal key with no mathematical conversion, hashing, or derivation.
## Derivation Algorithm
### Step 1 -- Parse the Public Key
A Nostr pubkey is a 64-character hex string representing 32 bytes. Convert it to a byte buffer:
```
pubkey (hex): e7a2e3b5f1c8d4a6... (64 hex chars = 32 bytes)
pubkeyBuffer: <Buffer e7 a2 e3 b5 f1 c8 d4 a6 ...>
```
### Step 2 -- Compute the Taproot Output Key
Bitcoin Taproot (BIP-341) defines a "tweaking" process for the internal key:
```
t = taggedHash("TapTweak", internalPubkey)
Q = P + t*G (where P = internal key, G = generator point)
```
When there is no script tree (key-path-only spend), only the internal key participates in the tweak. The result `Q` is the **output key** that appears on-chain.
This step is handled internally by `bitcoinjs-lib`'s `payments.p2tr()`.
### Step 3 -- Encode as a bech32m Address
The 32-byte output key `Q` is encoded with:
- Witness version: **1** (Taproot)
- Encoding: **bech32m** (BIP-350)
- Human-readable prefix: `bc` (mainnet)
The resulting address always starts with `bc1p`.
### Implementation
```typescript
import * as bitcoin from 'bitcoinjs-lib';
function nostrPubkeyToBitcoinAddress(pubkeyHex: string): string {
const pubkeyBuffer = Buffer.from(pubkeyHex, 'hex');
const { address } = bitcoin.payments.p2tr({
internalPubkey: pubkeyBuffer,
network: bitcoin.networks.bitcoin,
});
return address; // "bc1p..."
}
```
### Example
```
Nostr pubkey (hex): 82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2
Bitcoin address: bc1pw0qkazw9twl4snwxal6v90djv3c8cph4s0w7rvtyp3k95rll3cqqhv4cn8
```
## Dependencies
| Package | Role |
|---|---|
| `bitcoinjs-lib` | P2TR address generation, PSBT construction |
| `@bitcoinerlab/secp256k1` | secp256k1 ECC operations (Schnorr, key tweaking) |
| `buffer` | Node.js Buffer polyfill for the browser |
The ECC library must be initialized once at startup:
```typescript
import * as bitcoin from 'bitcoinjs-lib';
import * as ecc from '@bitcoinerlab/secp256k1';
bitcoin.initEccLib(ecc);
```
## Balance & Transaction APIs
All Bitcoin data is fetched from the public [mempool.space](https://mempool.space) Esplora-compatible API:
| Endpoint | Purpose |
|---|---|
| `GET https://mempool.space/api/address/{address}` | Balance stats (funded/spent sums, tx counts) |
| `GET https://mempool.space/api/address/{address}/txs` | Transaction history for an address |
| `GET https://mempool.space/api/tx/{txid}` | Full transaction detail (inputs, outputs, fee, block) |
The wallet page polls balance and transaction data every 30 seconds. BTC/USD price is fetched from CoinGecko every 60 seconds.
## NIP-73 Integration
Transaction and address detail pages use [NIP-73](https://github.com/nostr-protocol/nips/blob/master/73.md) external content identifiers, enabling Nostr comments and reactions on Bitcoin transactions and addresses:
- **Transaction pages**: `/i/bitcoin:tx:{txid}` -- renders a mempool.space-style transaction view with inputs, outputs, fee, block info, and USD values
- **Address pages**: `/i/bitcoin:address:{address}` -- renders balance, recent transactions, and total received/sent
These pages are part of the existing `/i/*` external content system, which also supports URLs, ISBNs, country codes, and other NIP-73 identifier types.
## Sending Bitcoin
The wallet supports sending Bitcoin transactions directly from the app. Because Nostr and Bitcoin Taproot share the same private key, the Nostr key can sign Bitcoin transactions without any key conversion.
### Supported Signer Types
Sending works with all three login types:
| Login type | Signing method | How it works |
|---|---|---|
| **nsec** (secret key) | Local signing | The app applies the BIP-341 TapTweak and signs the PSBT directly using the private key. |
| **NIP-07 extension** | `window.nostr.signPsbt(hex)` | The unsigned PSBT hex is passed to the extension, which handles tweaking and signing internally. |
| **NIP-46 bunker** | `sign_psbt` RPC | The unsigned PSBT hex is sent to the remote signer over the NIP-46 relay channel. |
If the signer does not support PSBT signing (e.g. an extension without `signPsbt`), the send dialog displays an explanation and the user cannot proceed.
### Architecture
The send flow uses a three-step PSBT pipeline:
1. **`buildUnsignedPsbt()`** -- Constructs the PSBT with all inputs and outputs but no signatures. Only needs the sender's public key.
2. **`signer.signPsbt()`** -- Signs the PSBT. Dispatched through the signer interface (local, extension, or bunker).
3. **`finalizePsbt()`** -- Finalizes all inputs and extracts the raw transaction hex for broadcast.
The signer classes (`NSecSignerBtc`, `NBrowserSignerBtc`, `NConnectSignerBtc`) extend Nostrify's base signer classes with a `signPsbt` method. The `signerWithNudge` wrapper forwards `signPsbt` to the underlying signer, providing the same nudge toast UX for remote signers.
### Transaction Construction
The send flow constructs a standard Taproot (P2TR) key-path spend:
1. **Fetch UTXOs** -- All unspent outputs for the sender's address are retrieved from `mempool.space/api/address/{address}/utxo`.
2. **Fetch fee rates** -- Recommended fee rates (sat/vB) for four confirmation targets are retrieved from `mempool.space/api/fee-estimates`:
| Speed | Block target | Typical wait |
|---|---|---|
| Fastest | 1 block | ~10 minutes |
| Half hour | 3 blocks | ~30 minutes |
| One hour | 6 blocks | ~1 hour |
| Economy | 144 blocks | ~1 day |
3. **Resolve recipient** -- The recipient can be an `npub1...` (converted to its Taproot address via `npubToBitcoinAddress`) or a raw Bitcoin address (validated via `bitcoin.address.toOutputScript`).
4. **Build unsigned PSBT** -- All UTXOs are consumed as inputs (no coin selection). Each input includes:
- `witnessUtxo`: the P2TR output script and value
- `tapInternalKey`: the 32-byte x-only public key
5. **Estimate fee** -- The formula is `ceil((numInputs * 57.5 + numOutputs * 43 + 10.5) * feeRate)`. The output count is determined dynamically: if the change would be below the 546-sat dust limit, it is dropped (donated as extra miner fee) and the estimate uses 1 output instead of 2.
6. **Add outputs** -- The recipient output is always added. A change output (back to the sender's own Taproot address) is added only if the change exceeds the dust limit.
7. **Sign** -- The unsigned PSBT is passed to the signer's `signPsbt` method. For local nsec signing, the private key is tweaked for Taproot key-path spending (BIP-341):
```
tweak = taggedHash("TapTweak", internalPubkey)
tweakedKey = privateKey + tweak (mod n, with y-parity correction)
```
For extension and bunker signers, the tweaking is handled by the external signer.
8. **Finalize and broadcast** -- The signed PSBT is finalized, the raw transaction hex is extracted, and POSTed to `mempool.space/api/tx`, which returns the txid on success.
### User Flow
The send dialog has three steps:
1. **Form** -- Enter recipient, amount, and fee speed. Shows available balance, USD conversion, and a "Send Max" button that correctly subtracts the estimated fee.
2. **Confirm** -- Review recipient address, amount (BTC + USD), fee breakdown, and total debit before committing.
3. **Success** -- Shows the transaction ID with a link to the in-app NIP-73 detail page (`/i/bitcoin:tx:{txid}`).
### Additional API Endpoints
| Endpoint | Purpose |
|---|---|
| `GET https://mempool.space/api/address/{address}/utxo` | Unspent transaction outputs |
| `GET https://mempool.space/api/fee-estimates` | Recommended fee rates by block target |
| `POST https://mempool.space/api/tx` | Broadcast signed transaction (raw hex body) |
### Dependencies (sending-specific)
| Package | Role |
|---|---|
| `ecpair` | Key pair creation and Taproot key tweaking |
| `tiny-secp256k1` | Low-level ECC operations (peer dep of ecpair) |
These are in addition to the base dependencies listed above.
## Security Considerations
- The same private key (nsec in Nostr) controls both the Nostr identity and the Bitcoin funds at the derived address.
- Extension and bunker signers handle the private key internally -- the app never sees the raw key for those login types. Only the unsigned PSBT (which contains no secret material) is sent to the signer.
- This is a **single-key** Taproot address with no HD derivation (no BIP-32/BIP-44 path). Every Nostr keypair maps to exactly one Bitcoin address.
- Users should ensure they have secure backups of their Nostr private key before receiving Bitcoin at the derived address.
- Extensions and bunkers that support `signPsbt` / `sign_psbt` should display a confirmation dialog showing transaction outputs and amounts before signing.
## References
- [BIP-340: Schnorr Signatures for secp256k1](https://github.com/bitcoin/bips/blob/master/bip-0340.mediawiki)
- [BIP-341: Taproot (SegWit v1)](https://github.com/bitcoin/bips/blob/master/bip-0341.mediawiki)
- [BIP-350: Bech32m](https://github.com/bitcoin/bips/blob/master/bip-0350.mediawiki)
- [NIP-01: Basic Protocol](https://github.com/nostr-protocol/nips/blob/master/01.md) (defines secp256k1 x-only keys for Nostr)
+198 -2
View File
@@ -1,13 +1,14 @@
{
"name": "ditto",
"version": "2.11.1",
"version": "2.11.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ditto",
"version": "2.11.1",
"version": "2.11.2",
"dependencies": {
"@bitcoinerlab/secp256k1": "^1.2.0",
"@capacitor/app": "^8.0.0",
"@capacitor/core": "^8.1.0",
"@capacitor/filesystem": "^8.1.2",
@@ -95,6 +96,7 @@
"@tanstack/react-query": "^5.56.2",
"@unhead/addons": "^2.1.13",
"@unhead/react": "^2.1.13",
"bitcoinjs-lib": "^7.0.1",
"blurhash": "^2.0.5",
"buffer": "^6.0.3",
"capacitor-secure-storage-plugin": "^0.13.0",
@@ -104,6 +106,7 @@
"d3-celestial": "^0.7.35",
"date-fns": "^3.6.0",
"dompurify": "^3.3.3",
"ecpair": "^3.0.1",
"embla-carousel-react": "^8.3.0",
"emoji-mart": "^5.6.0",
"fflate": "^0.8.2",
@@ -130,6 +133,7 @@
"smol-toml": "^1.6.0",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",
"tiny-secp256k1": "^2.2.4",
"uri-templates": "^0.2.0",
"vaul": "^1.1.2",
"zod": "^4.3.6"
@@ -337,6 +341,42 @@
"node": ">=6.9.0"
}
},
"node_modules/@bitcoinerlab/secp256k1": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@bitcoinerlab/secp256k1/-/secp256k1-1.2.0.tgz",
"integrity": "sha512-jeujZSzb3JOZfmJYI0ph1PVpCRV5oaexCgy+RvCXV8XlY+XFB/2n3WOcvBsKLsOw78KYgnQrQWb2HrKE4be88Q==",
"license": "MIT",
"dependencies": {
"@noble/curves": "^1.7.0"
}
},
"node_modules/@bitcoinerlab/secp256k1/node_modules/@noble/curves": {
"version": "1.9.7",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz",
"integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "1.8.0"
},
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@bitcoinerlab/secp256k1/node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@capacitor/android": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/@capacitor/android/-/android-8.1.0.tgz",
@@ -7545,6 +7585,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/base-x": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/base-x/-/base-x-5.0.1.tgz",
"integrity": "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==",
"license": "MIT"
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -7565,6 +7611,12 @@
],
"license": "MIT"
},
"node_modules/bech32": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/bech32/-/bech32-2.0.0.tgz",
"integrity": "sha512-LcknSilhIGatDAsY1ak2I8VtGaHNhgMSYVxFrGLXv+xLHytaKZKcaUJJUE7qmBr7h33o5YQwP55pMI0xmkpJwg==",
"license": "MIT"
},
"node_modules/big-integer": {
"version": "1.6.52",
"resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz",
@@ -7587,6 +7639,37 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/bip174": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/bip174/-/bip174-3.0.0.tgz",
"integrity": "sha512-N3vz3rqikLEu0d6yQL8GTrSkpYb35NQKWMR7Hlza0lOj6ZOlvQ3Xr7N9Y+JPebaCVoEUHdBeBSuLxcHr71r+Lw==",
"license": "MIT",
"dependencies": {
"uint8array-tools": "^0.0.9",
"varuint-bitcoin": "^2.0.0"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/bitcoinjs-lib": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/bitcoinjs-lib/-/bitcoinjs-lib-7.0.1.tgz",
"integrity": "sha512-vwEmpL5Tpj0I0RBdNkcDMXePoaYSTeKY6mL6/l5esbnTs+jGdPDuLp4NY1hSh6Zk5wSgePygZ4Wx5JJao30Pww==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "^1.2.0",
"bech32": "^2.0.0",
"bip174": "^3.0.0",
"bs58check": "^4.0.0",
"uint8array-tools": "^0.0.9",
"valibot": "^1.2.0",
"varuint-bitcoin": "^2.0.0"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/blurhash": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/blurhash/-/blurhash-2.0.5.tgz",
@@ -7662,6 +7745,25 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/bs58": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/bs58/-/bs58-6.0.0.tgz",
"integrity": "sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==",
"license": "MIT",
"dependencies": {
"base-x": "^5.0.0"
}
},
"node_modules/bs58check": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/bs58check/-/bs58check-4.0.0.tgz",
"integrity": "sha512-FsGDOnFg9aVI9erdriULkd/JjEWONV/lQE5aYziB5PoBsXRind56lh8doIZIc9X4HoxT5x4bLjMWN1/NB8Zp5g==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "^1.2.0",
"bs58": "^6.0.0"
}
},
"node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
@@ -8464,6 +8566,29 @@
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/ecpair": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/ecpair/-/ecpair-3.0.1.tgz",
"integrity": "sha512-uz8wMFvtdr58TLrXnAesBsoMEyY8UudLOfApcyg40XfZjP+gt1xO4cuZSIkZ8hTMTQ8+ETgt7xSIV4eM7M6VNw==",
"license": "MIT",
"dependencies": {
"uint8array-tools": "^0.0.8",
"valibot": "^1.2.0",
"wif": "^5.0.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/ecpair/node_modules/uint8array-tools": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/uint8array-tools/-/uint8array-tools-0.0.8.tgz",
"integrity": "sha512-xS6+s8e0Xbx++5/0L+yyexukU7pz//Yg6IHg3BKhXotg1JcYtgxVcUctQ0HxLByiJzpAkNFawz1Nz5Xadzo82g==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.149",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.149.tgz",
@@ -13550,6 +13675,27 @@
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tiny-secp256k1": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/tiny-secp256k1/-/tiny-secp256k1-2.2.4.tgz",
"integrity": "sha512-FoDTcToPqZE454Q04hH9o2EhxWsm7pOSpicyHkgTwKhdKWdsTUuqfP5MLq3g+VjAtl2vSx6JpXGdwA2qpYkI0Q==",
"license": "MIT",
"dependencies": {
"uint8array-tools": "0.0.7"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tiny-secp256k1/node_modules/uint8array-tools": {
"version": "0.0.7",
"resolved": "https://registry.npmjs.org/uint8array-tools/-/uint8array-tools-0.0.7.tgz",
"integrity": "sha512-vrrNZJiusLWoFWBqz5Y5KMCgP9W9hnjZHzZiZRT8oNAkq3d5Z5Oe76jAvVVSRh4U8GGR90N2X1dWtrhvx6L8UQ==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@@ -13809,6 +13955,15 @@
"integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==",
"license": "MIT"
},
"node_modules/uint8array-tools": {
"version": "0.0.9",
"resolved": "https://registry.npmjs.org/uint8array-tools/-/uint8array-tools-0.0.9.tgz",
"integrity": "sha512-9vqDWmoSXOoi+K14zNaf6LBV51Q8MayF0/IiQs3GlygIKUYtog603e6virExkjjFosfJUBI4LhbQK1iq8IG11A==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
@@ -14146,6 +14301,38 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/valibot": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/valibot/-/valibot-1.3.1.tgz",
"integrity": "sha512-sfdRir/QFM0JaF22hqTroPc5xy4DimuGQVKFrzF1YfGwaS1nJot3Y8VqMdLO2Lg27fMzat2yD3pY5PbAYO39Gg==",
"license": "MIT",
"peerDependencies": {
"typescript": ">=5"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/varuint-bitcoin": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/varuint-bitcoin/-/varuint-bitcoin-2.0.0.tgz",
"integrity": "sha512-6QZbU/rHO2ZQYpWFDALCDSRsXbAs1VOEmXAxtbtjLtKuMJ/FQ8YbhfxlaiKv5nklci0M6lZtlZyxo9Q+qNnyog==",
"license": "MIT",
"dependencies": {
"uint8array-tools": "^0.0.8"
}
},
"node_modules/varuint-bitcoin/node_modules/uint8array-tools": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/uint8array-tools/-/uint8array-tools-0.0.8.tgz",
"integrity": "sha512-xS6+s8e0Xbx++5/0L+yyexukU7pz//Yg6IHg3BKhXotg1JcYtgxVcUctQ0HxLByiJzpAkNFawz1Nz5Xadzo82g==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/vaul": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz",
@@ -15739,6 +15926,15 @@
"node": ">=8"
}
},
"node_modules/wif": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/wif/-/wif-5.0.0.tgz",
"integrity": "sha512-iFzrC/9ne740qFbNjTZ2FciSRJlHIXoxqk/Y5EnE08QOXu1WjJyCCswwDTYbohAOEnlCtLaAAQBhyaLRFh2hMA==",
"license": "MIT",
"dependencies": {
"bs58check": "^4.0.0"
}
},
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+4
View File
@@ -15,6 +15,7 @@
"node": ">=22"
},
"dependencies": {
"@bitcoinerlab/secp256k1": "^1.2.0",
"@capacitor/app": "^8.0.0",
"@capacitor/core": "^8.1.0",
"@capacitor/filesystem": "^8.1.2",
@@ -102,6 +103,7 @@
"@tanstack/react-query": "^5.56.2",
"@unhead/addons": "^2.1.13",
"@unhead/react": "^2.1.13",
"bitcoinjs-lib": "^7.0.1",
"blurhash": "^2.0.5",
"buffer": "^6.0.3",
"capacitor-secure-storage-plugin": "^0.13.0",
@@ -111,6 +113,7 @@
"d3-celestial": "^0.7.35",
"date-fns": "^3.6.0",
"dompurify": "^3.3.3",
"ecpair": "^3.0.1",
"embla-carousel-react": "^8.3.0",
"emoji-mart": "^5.6.0",
"fflate": "^0.8.2",
@@ -137,6 +140,7 @@
"smol-toml": "^1.6.0",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",
"tiny-secp256k1": "^2.2.4",
"uri-templates": "^0.2.0",
"vaul": "^1.1.2",
"zod": "^4.3.6"
+2
View File
@@ -73,6 +73,7 @@ const TrendsPage = lazy(() => import("./pages/TrendsPage").then(m => ({ default:
const UserListsPage = lazy(() => import("./pages/UserListsPage").then(m => ({ default: m.UserListsPage })));
const VideosFeedPage = lazy(() => import("./pages/VideosFeedPage").then(m => ({ default: m.VideosFeedPage })));
const VinesFeedPage = lazy(() => import("./pages/VinesFeedPage").then(m => ({ default: m.VinesFeedPage })));
const WalletPage = lazy(() => import("./pages/WalletPage").then(m => ({ default: m.WalletPage })));
const WalletSettingsPage = lazy(() => import("./pages/WalletSettingsPage").then(m => ({ default: m.WalletSettingsPage })));
const WebxdcFeedPage = lazy(() => import("./pages/WebxdcFeedPage").then(m => ({ default: m.WebxdcFeedPage })));
const WikipediaPage = lazy(() => import("./pages/WikipediaPage").then(m => ({ default: m.WikipediaPage })));
@@ -255,6 +256,7 @@ export function AppRouter() {
}
/>
<Route path="/themes" element={<ThemesPage />} />
<Route path="/wallet" element={<WalletPage />} />
<Route path="/bookmarks" element={<BookmarksPage />} />
<Route path="/ai-chat" element={<AIChatPage />} />
<Route path="/blobbi" element={<BlobbiPage />} />
+631
View File
@@ -0,0 +1,631 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import {
ArrowDownLeft,
ArrowRight,
ArrowUpRight,
Bitcoin,
Check,
Clock,
Copy,
ExternalLink,
Hash,
Layers,
RefreshCw,
Weight,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { useBitcoinTx } from '@/hooks/useBitcoinTx';
import { useBitcoinAddress } from '@/hooks/useBitcoinAddress';
import { satsToBTC, satsToUSD, formatSats, formatBTC } from '@/lib/bitcoin';
import type { TxDetail, TxInput, TxOutput } from '@/lib/bitcoin';
// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------
function truncateMiddle(str: string, startLen = 8, endLen = 8): string {
if (str.length <= startLen + endLen + 3) return str;
return `${str.slice(0, startLen)}...${str.slice(-endLen)}`;
}
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// clipboard not available
}
};
return (
<button
onClick={handleCopy}
className="p-1 rounded hover:bg-muted/50 transition-colors text-muted-foreground hover:text-foreground cursor-pointer"
title="Copy"
>
{copied ? <Check className="size-3.5 text-green-500" /> : <Copy className="size-3.5" />}
</button>
);
}
/** Format a unix timestamp as a readable date string. */
function formatBlockTime(timestamp: number): string {
const date = new Date(timestamp * 1000);
return date.toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: true,
});
}
/** Format a large number with locale separators. */
function formatNumber(n: number): string {
return n.toLocaleString();
}
// ---------------------------------------------------------------------------
// Bitcoin Transaction Header
// ---------------------------------------------------------------------------
export function BitcoinTxHeader({ txid }: { txid: string }) {
const { tx, btcPrice, isLoading, error } = useBitcoinTx(txid);
if (isLoading) return <TxSkeleton />;
if (error || !tx) {
return (
<div className="rounded-2xl border border-border p-6 text-center space-y-3">
<Bitcoin className="size-10 mx-auto text-muted-foreground/40" />
<p className="text-sm text-destructive">Failed to load transaction</p>
<p className="text-xs text-muted-foreground font-mono break-all">{txid}</p>
</div>
);
}
return (
<div className="rounded-2xl border border-border overflow-hidden">
{/* Header */}
<div className="p-5 space-y-4">
<div className="flex items-center gap-3">
<div className={`flex items-center justify-center size-10 rounded-full ${
tx.confirmed
? 'bg-green-500/10 text-green-600 dark:text-green-400'
: 'bg-orange-500/10 text-orange-600 dark:text-orange-400'
}`}>
{tx.confirmed ? <Check className="size-5" /> : <Clock className="size-5" />}
</div>
<div>
<h2 className="text-lg font-bold">
{tx.confirmed ? 'Confirmed' : 'Unconfirmed'}
</h2>
{tx.blockTime && (
<p className="text-sm text-muted-foreground">{formatBlockTime(tx.blockTime)}</p>
)}
</div>
</div>
{/* Transaction ID */}
<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Transaction ID</p>
<div className="flex items-center gap-2">
<p className="text-sm font-mono text-foreground break-all">{tx.txid}</p>
<CopyButton text={tx.txid} />
</div>
</div>
{/* Stats grid */}
<div className="grid grid-cols-2 gap-3">
{tx.confirmed && tx.blockHeight !== undefined && (
<StatCard icon={<Layers className="size-3.5" />} label="Block" value={formatNumber(tx.blockHeight)} />
)}
<StatCard icon={<Weight className="size-3.5" />} label="Size" value={`${formatNumber(tx.weight / 4)} vB`} />
<StatCard
icon={<Bitcoin className="size-3.5" />}
label="Fee"
value={`${formatSats(tx.fee)} sat`}
subtitle={`${(tx.fee / (tx.weight / 4)).toFixed(1)} sat/vB`}
/>
<StatCard
icon={<Hash className="size-3.5" />}
label="Amount"
value={`${formatBTC(tx.totalOutput)} BTC`}
subtitle={btcPrice ? satsToUSD(tx.totalOutput, btcPrice) : undefined}
/>
</div>
</div>
{/* Inputs → Outputs flow */}
<div className="border-t border-border">
<TxFlow tx={tx} btcPrice={btcPrice} />
</div>
{/* Footer: link to mempool.space */}
<div className="border-t border-border px-5 py-2.5">
<a
href={`https://mempool.space/tx/${txid}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<Bitcoin className="size-3.5" />
<span>View on mempool.space</span>
<ExternalLink className="size-3" />
</a>
</div>
</div>
);
}
function StatCard({ icon, label, value, subtitle }: { icon: React.ReactNode; label: string; value: string; subtitle?: string }) {
return (
<div className="rounded-xl bg-secondary/40 px-3.5 py-2.5 space-y-0.5">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
{icon}
<span>{label}</span>
</div>
<p className="text-sm font-semibold">{value}</p>
{subtitle && <p className="text-xs text-muted-foreground">{subtitle}</p>}
</div>
);
}
/** Inputs → Outputs visualization, mempool.space-style. */
function TxFlow({ tx, btcPrice }: { tx: TxDetail; btcPrice?: number }) {
return (
<div className="p-4 space-y-3">
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground uppercase tracking-wider px-1">
<span>{tx.inputs.length} Input{tx.inputs.length !== 1 ? 's' : ''}</span>
<ArrowRight className="size-3" />
<span>{tx.outputs.length} Output{tx.outputs.length !== 1 ? 's' : ''}</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{/* Inputs */}
<div className="space-y-1.5">
{tx.inputs.slice(0, 10).map((input, i) => (
<TxInputRow key={`${input.txid}-${input.vout}-${i}`} input={input} btcPrice={btcPrice} />
))}
{tx.inputs.length > 10 && (
<p className="text-xs text-muted-foreground text-center py-1">
+{tx.inputs.length - 10} more input{tx.inputs.length - 10 !== 1 ? 's' : ''}
</p>
)}
</div>
{/* Outputs */}
<div className="space-y-1.5">
{tx.outputs.slice(0, 10).map((output, i) => (
<TxOutputRow key={`${output.address ?? 'op_return'}-${i}`} output={output} btcPrice={btcPrice} />
))}
{tx.outputs.length > 10 && (
<p className="text-xs text-muted-foreground text-center py-1">
+{tx.outputs.length - 10} more output{tx.outputs.length - 10 !== 1 ? 's' : ''}
</p>
)}
</div>
</div>
</div>
);
}
function TxInputRow({ input, btcPrice }: { input: TxInput; btcPrice?: number }) {
if (input.isCoinbase) {
return (
<div className="rounded-lg bg-amber-500/5 border border-amber-500/20 px-3 py-2">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium text-amber-600 dark:text-amber-400">Coinbase</span>
<span className="text-xs font-mono">{formatBTC(input.value)} BTC</span>
</div>
</div>
);
}
return (
<div className="rounded-lg bg-red-500/5 border border-red-500/10 px-3 py-2 space-y-0.5">
<div className="flex items-center justify-between gap-2">
{input.address ? (
<Link
to={`/i/bitcoin:address:${input.address}`}
className="text-xs font-mono text-red-600 dark:text-red-400 hover:underline truncate"
>
{truncateMiddle(input.address, 10, 6)}
</Link>
) : (
<span className="text-xs text-muted-foreground">Unknown</span>
)}
<span className="text-xs font-mono shrink-0">{formatBTC(input.value)} BTC</span>
</div>
{btcPrice !== undefined && (
<p className="text-[10px] text-muted-foreground text-right">{satsToUSD(input.value, btcPrice)}</p>
)}
</div>
);
}
function TxOutputRow({ output, btcPrice }: { output: TxOutput; btcPrice?: number }) {
const isOpReturn = output.scriptpubkeyType === 'op_return';
if (isOpReturn) {
return (
<div className="rounded-lg bg-secondary/60 border border-border/50 px-3 py-2">
<span className="text-xs text-muted-foreground">OP_RETURN</span>
</div>
);
}
return (
<div className="rounded-lg bg-green-500/5 border border-green-500/10 px-3 py-2 space-y-0.5">
<div className="flex items-center justify-between gap-2">
{output.address ? (
<Link
to={`/i/bitcoin:address:${output.address}`}
className="text-xs font-mono text-green-600 dark:text-green-400 hover:underline truncate"
>
{truncateMiddle(output.address, 10, 6)}
</Link>
) : (
<span className="text-xs text-muted-foreground">Unknown</span>
)}
<span className="text-xs font-mono shrink-0">{formatBTC(output.value)} BTC</span>
</div>
{btcPrice !== undefined && (
<p className="text-[10px] text-muted-foreground text-right">{satsToUSD(output.value, btcPrice)}</p>
)}
</div>
);
}
function TxSkeleton() {
return (
<div className="rounded-2xl border border-border overflow-hidden">
<div className="p-5 space-y-4">
<div className="flex items-center gap-3">
<Skeleton className="size-10 rounded-full" />
<div className="space-y-1.5">
<Skeleton className="h-5 w-28" />
<Skeleton className="h-3.5 w-40" />
</div>
</div>
<div className="space-y-1.5">
<Skeleton className="h-3 w-24" />
<Skeleton className="h-4 w-full" />
</div>
<div className="grid grid-cols-2 gap-3">
<Skeleton className="h-16 rounded-xl" />
<Skeleton className="h-16 rounded-xl" />
<Skeleton className="h-16 rounded-xl" />
<Skeleton className="h-16 rounded-xl" />
</div>
</div>
<div className="border-t border-border p-4 space-y-3">
<Skeleton className="h-3 w-32" />
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Skeleton className="h-12 rounded-lg" />
<Skeleton className="h-12 rounded-lg" />
</div>
<div className="space-y-1.5">
<Skeleton className="h-12 rounded-lg" />
<Skeleton className="h-12 rounded-lg" />
</div>
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Bitcoin Address Header
// ---------------------------------------------------------------------------
export function BitcoinAddressHeader({ address }: { address: string }) {
const { addressDetail, btcPrice, isLoading, error, refetch } = useBitcoinAddress(address);
if (isLoading) return <AddressSkeleton />;
if (error || !addressDetail) {
return (
<div className="rounded-2xl border border-border p-6 text-center space-y-3">
<Bitcoin className="size-10 mx-auto text-muted-foreground/40" />
<p className="text-sm text-destructive">Failed to load address</p>
<p className="text-xs text-muted-foreground font-mono break-all">{address}</p>
<Button variant="outline" size="sm" onClick={() => refetch()}>
<RefreshCw className="size-3.5 mr-1.5" />
Retry
</Button>
</div>
);
}
return (
<div className="rounded-2xl border border-border overflow-hidden">
{/* Header */}
<div className="p-5 space-y-4">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center size-10 rounded-full bg-primary/10 text-primary">
<Bitcoin className="size-5" />
</div>
<div>
<h2 className="text-lg font-bold">Bitcoin Address</h2>
<p className="text-xs text-muted-foreground">
{addressDetail.txCount + addressDetail.pendingTxCount} transaction{(addressDetail.txCount + addressDetail.pendingTxCount) !== 1 ? 's' : ''}
</p>
</div>
</div>
{/* Address */}
<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Address</p>
<div className="flex items-center gap-2">
<p className="text-sm font-mono text-foreground break-all">{address}</p>
<CopyButton text={address} />
</div>
</div>
{/* Balance hero */}
<div className="rounded-xl bg-secondary/40 p-4 text-center space-y-1">
<p className="text-xs text-muted-foreground uppercase tracking-wider">Balance</p>
<p className="text-3xl font-bold tracking-tight">
{btcPrice ? satsToUSD(addressDetail.totalBalance, btcPrice) : `${formatBTC(addressDetail.totalBalance)} BTC`}
</p>
<p className="text-sm text-muted-foreground">
{formatBTC(addressDetail.totalBalance)} BTC
</p>
{addressDetail.pendingBalance !== 0 && (
<p className="flex items-center justify-center gap-1 text-xs text-orange-500 dark:text-orange-400 pt-1">
<RefreshCw className="size-3 animate-spin" />
{btcPrice
? `${satsToUSD(addressDetail.pendingBalance, btcPrice)} pending`
: `${formatBTC(addressDetail.pendingBalance)} BTC pending`}
</p>
)}
</div>
{/* Stats grid */}
<div className="grid grid-cols-2 gap-3">
<StatCard
icon={<ArrowDownLeft className="size-3.5" />}
label="Total Received"
value={`${formatBTC(addressDetail.totalReceived)} BTC`}
subtitle={btcPrice ? satsToUSD(addressDetail.totalReceived, btcPrice) : undefined}
/>
<StatCard
icon={<ArrowUpRight className="size-3.5" />}
label="Total Sent"
value={`${formatBTC(addressDetail.totalSent)} BTC`}
subtitle={btcPrice ? satsToUSD(addressDetail.totalSent, btcPrice) : undefined}
/>
</div>
</div>
{/* Recent Transactions */}
{addressDetail.recentTxs.length > 0 && (
<div className="border-t border-border">
<div className="px-5 py-3">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Recent Transactions
</p>
</div>
<div className="divide-y divide-border">
{addressDetail.recentTxs.slice(0, 10).map((tx) => (
<AddressTxRow key={tx.txid} tx={tx} btcPrice={btcPrice} />
))}
</div>
{addressDetail.recentTxs.length > 10 && (
<div className="px-5 py-3 text-center">
<p className="text-xs text-muted-foreground">
{addressDetail.txCount - 10} more transaction{addressDetail.txCount - 10 !== 1 ? 's' : ''}
</p>
</div>
)}
</div>
)}
{/* Footer: link to mempool.space */}
<div className="border-t border-border px-5 py-2.5">
<a
href={`https://mempool.space/address/${address}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<Bitcoin className="size-3.5" />
<span>View on mempool.space</span>
<ExternalLink className="size-3" />
</a>
</div>
</div>
);
}
function AddressTxRow({ tx, btcPrice }: { tx: { txid: string; amount: number; type: 'receive' | 'send'; confirmed: boolean; timestamp?: number }; btcPrice?: number }) {
const isReceive = tx.type === 'receive';
return (
<Link
to={`/i/bitcoin:tx:${tx.txid}`}
className="flex items-center justify-between py-3 px-5 hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-3">
<div className={`flex items-center justify-center size-8 rounded-full ${
isReceive
? 'bg-green-500/10 text-green-600 dark:text-green-400'
: 'bg-red-500/10 text-red-600 dark:text-red-400'
}`}>
{isReceive ? <ArrowDownLeft className="size-4" /> : <ArrowUpRight className="size-4" />}
</div>
<div>
<p className="text-sm font-medium">{isReceive ? 'Received' : 'Sent'}</p>
<p className="text-xs text-muted-foreground font-mono">{truncateMiddle(tx.txid, 8, 8)}</p>
</div>
</div>
<div className="text-right">
<p className={`text-sm font-medium ${
isReceive ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'
}`}>
{isReceive ? '+' : '-'}{formatBTC(tx.amount)} BTC
</p>
{btcPrice && (
<p className="text-xs text-muted-foreground">
{satsToUSD(tx.amount, btcPrice)}
</p>
)}
</div>
</Link>
);
}
function AddressSkeleton() {
return (
<div className="rounded-2xl border border-border overflow-hidden">
<div className="p-5 space-y-4">
<div className="flex items-center gap-3">
<Skeleton className="size-10 rounded-full" />
<div className="space-y-1.5">
<Skeleton className="h-5 w-32" />
<Skeleton className="h-3.5 w-24" />
</div>
</div>
<div className="space-y-1.5">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-4 w-full" />
</div>
<div className="rounded-xl bg-secondary/40 p-4 space-y-2 flex flex-col items-center">
<Skeleton className="h-3 w-12" />
<Skeleton className="h-9 w-40" />
<Skeleton className="h-4 w-28" />
</div>
<div className="grid grid-cols-2 gap-3">
<Skeleton className="h-16 rounded-xl" />
<Skeleton className="h-16 rounded-xl" />
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Compact previews (used in NoteCard embeds, hover cards, etc.)
// ---------------------------------------------------------------------------
/** Compact preview for a Bitcoin transaction — fetches real data. */
export function BitcoinTxPreview({ txid, link }: { txid: string; link: string }) {
const { tx, btcPrice, isLoading } = useBitcoinTx(txid);
if (isLoading) {
return (
<div className="px-4 py-3 border-b border-border">
<div className="flex items-center gap-3">
<Skeleton className="size-12 rounded-lg shrink-0" />
<div className="flex-1 min-w-0 space-y-1.5">
<Skeleton className="h-3 w-32" />
<Skeleton className="h-4 w-3/4" />
</div>
</div>
</div>
);
}
const amount = tx ? tx.totalOutput : 0;
const fee = tx?.fee ?? 0;
return (
<Link
to={link}
className="flex items-center gap-3 px-4 py-3 border-b border-border hover:bg-secondary/30 transition-colors"
>
<div className="size-12 rounded-lg bg-orange-500/10 flex items-center justify-center shrink-0">
<Bitcoin className="size-5 text-orange-600 dark:text-orange-400" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Bitcoin className="size-3 shrink-0" />
<span>Bitcoin Transaction</span>
{tx && (
<span className={tx.confirmed
? 'text-green-600 dark:text-green-400'
: 'text-yellow-600 dark:text-yellow-400'
}>
{tx.confirmed ? 'Confirmed' : 'Unconfirmed'}
</span>
)}
</div>
<p className="text-sm font-medium truncate mt-0.5">
{tx ? `${satsToBTC(amount)} BTC` : truncateMiddle(txid, 12, 8)}
{tx && btcPrice ? (
<span className="text-muted-foreground font-normal"> ({satsToUSD(amount, btcPrice)})</span>
) : null}
</p>
{tx && (
<p className="text-xs text-muted-foreground truncate">
Fee {formatSats(fee)} sats
{tx.blockHeight ? ` · Block ${tx.blockHeight.toLocaleString()}` : ''}
</p>
)}
</div>
<ExternalLink className="size-4 text-muted-foreground shrink-0" />
</Link>
);
}
/** Compact preview for a Bitcoin address — fetches real data. */
export function BitcoinAddressPreview({ address, link }: { address: string; link: string }) {
const { addressDetail, btcPrice, isLoading } = useBitcoinAddress(address);
if (isLoading) {
return (
<div className="px-4 py-3 border-b border-border">
<div className="flex items-center gap-3">
<Skeleton className="size-12 rounded-lg shrink-0" />
<div className="flex-1 min-w-0 space-y-1.5">
<Skeleton className="h-3 w-28" />
<Skeleton className="h-4 w-3/4" />
</div>
</div>
</div>
);
}
const balance = addressDetail?.totalBalance ?? 0;
const txCount = addressDetail ? addressDetail.txCount + addressDetail.pendingTxCount : 0;
return (
<Link
to={link}
className="flex items-center gap-3 px-4 py-3 border-b border-border hover:bg-secondary/30 transition-colors"
>
<div className="size-12 rounded-lg bg-orange-500/10 flex items-center justify-center shrink-0">
<Bitcoin className="size-5 text-orange-600 dark:text-orange-400" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Bitcoin className="size-3 shrink-0" />
<span>Bitcoin Address</span>
</div>
<p className="text-sm font-medium truncate mt-0.5">
{addressDetail ? `${satsToBTC(balance)} BTC` : truncateMiddle(address, 12, 8)}
{addressDetail && btcPrice ? (
<span className="text-muted-foreground font-normal"> ({satsToUSD(balance, btcPrice)})</span>
) : null}
</p>
{addressDetail && (
<p className="text-xs text-muted-foreground truncate">
{txCount.toLocaleString()} transaction{txCount !== 1 ? 's' : ''}
{' · '}
<span className="font-mono">{truncateMiddle(address, 8, 6)}</span>
</p>
)}
</div>
<ExternalLink className="size-4 text-muted-foreground shrink-0" />
</Link>
);
}
+1 -2
View File
@@ -24,7 +24,6 @@ import { useOpenPost } from '@/hooks/useOpenPost';
import { useBookSummary } from '@/hooks/useBookSummary';
import { getDisplayName } from '@/lib/getDisplayName';
import { timeAgo } from '@/lib/timeAgo';
import { canZap } from '@/lib/canZap';
import { formatNumber } from '@/lib/formatNumber';
import { cn } from '@/lib/utils';
import { BOOKSTR_KINDS, extractISBNFromEvent, parseBookReview, ratingToStars } from '@/lib/bookstr';
@@ -60,7 +59,7 @@ export function BookFeedItem({ event, className }: BookFeedItemProps) {
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
const [replyOpen, setReplyOpen] = useState(false);
const canZapAuthor = user && canZap(metadata);
const canZapAuthor = !!user && user.pubkey !== event.pubkey;
const isbn = useMemo(() => extractISBNFromEvent(event), [event]);
const isReview = event.kind === BOOKSTR_KINDS.BOOK_REVIEW;
+80 -1
View File
@@ -3,13 +3,14 @@ import { type ReactNode, useMemo } from 'react';
import { Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import {
Award, BarChart3, BookOpen, Bird, Camera, Clapperboard, Egg, FileText, Film,
Award, BarChart3, Bird, Bitcoin, BookOpen, Camera, Clapperboard, Egg, FileText, Film,
GitBranch, GitPullRequest, Mail, MapPin, MessageSquare, Mic, Music,
Package, Palette, PartyPopper, Podcast, Radio, Rocket, SmilePlus, Sparkles,
Stars, UserCheck, Users, Vote, Zap,
} from 'lucide-react';
import type { NostrEvent } from '@nostrify/nostrify';
import { BitcoinTxPreview, BitcoinAddressPreview } from '@/components/BitcoinContentHeader';
import { CardsIcon } from '@/components/icons/CardsIcon';
import { ChestIcon } from '@/components/icons/ChestIcon';
import { RepostIcon } from '@/components/icons/RepostIcon';
@@ -150,6 +151,7 @@ const KIND_LABELS: Record<number, string> = {
30621: 'a constellation',
39089: 'a follow pack',
9735: 'a zap',
8333: 'a Bitcoin zap',
31124: 'a Blobbi',
};
@@ -199,6 +201,7 @@ const KIND_ICONS: Partial<Record<number, React.ComponentType<{ className?: strin
39089: PartyPopper,
3367: Palette,
9735: Zap,
8333: Bitcoin,
31124: Egg,
2473: Bird,
30621: Stars,
@@ -742,6 +745,16 @@ function ExternalCommentContext({ root, className }: { root: CommentRoot; classN
return <CountryCommentContext identifier={identifier} className={className} />;
}
// Bitcoin transaction identifiers — show icon + truncated txid with hover preview
if (identifier.startsWith('bitcoin:tx:')) {
return <BitcoinTxCommentContext identifier={identifier} className={className} />;
}
// Bitcoin address identifiers — show icon + truncated address with hover preview
if (identifier.startsWith('bitcoin:address:')) {
return <BitcoinAddressCommentContext identifier={identifier} className={className} />;
}
// Generic fallback for other external identifiers
const link = `/i/${encodeURIComponent(identifier)}`;
@@ -996,3 +1009,69 @@ function GathererCardCommentContext({
</CommentContextRow>
);
}
/** Comment context for Bitcoin transaction identifiers — shows icon, truncated txid, and hover preview. */
function BitcoinTxCommentContext({ identifier, className }: { identifier: string; className?: string }) {
const txid = identifier.slice('bitcoin:tx:'.length);
const link = `/i/${encodeURIComponent(identifier)}`;
const truncated = txid.length > 19 ? `${txid.slice(0, 8)}…${txid.slice(-8)}` : txid;
return (
<CommentContextRow prefix="Commenting on" className={className}>
<Bitcoin className="size-3.5 shrink-0 text-orange-500" />
<HoverCard openDelay={300} closeDelay={150}>
<HoverCardTrigger asChild>
<Link
to={link}
className="text-primary hover:underline truncate cursor-pointer"
onClick={(e) => e.stopPropagation()}
>
transaction <span className="font-mono text-xs">{truncated}</span>
</Link>
</HoverCardTrigger>
<HoverCardContent
side="bottom"
align="start"
sideOffset={4}
className="w-80 p-0 rounded-2xl shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<BitcoinTxPreview txid={txid} link={link} />
</HoverCardContent>
</HoverCard>
</CommentContextRow>
);
}
/** Comment context for Bitcoin address identifiers — shows icon, truncated address, and hover preview. */
function BitcoinAddressCommentContext({ identifier, className }: { identifier: string; className?: string }) {
const address = identifier.slice('bitcoin:address:'.length);
const link = `/i/${encodeURIComponent(identifier)}`;
const truncated = address.length > 19 ? `${address.slice(0, 8)}…${address.slice(-8)}` : address;
return (
<CommentContextRow prefix="Commenting on" className={className}>
<Bitcoin className="size-3.5 shrink-0 text-orange-500" />
<HoverCard openDelay={300} closeDelay={150}>
<HoverCardTrigger asChild>
<Link
to={link}
className="text-primary hover:underline truncate cursor-pointer"
onClick={(e) => e.stopPropagation()}
>
address <span className="font-mono text-xs">{truncated}</span>
</Link>
</HoverCardTrigger>
<HoverCardContent
side="bottom"
align="start"
sideOffset={4}
className="w-80 p-0 rounded-2xl shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<BitcoinAddressPreview address={address} link={link} />
</HoverCardContent>
</HoverCard>
</CommentContextRow>
);
}
+5
View File
@@ -11,6 +11,7 @@ import { LinkEmbed } from '@/components/LinkEmbed';
import { ReplyComposeModal } from '@/components/ReplyComposeModal';
import { WikipediaIcon } from '@/components/icons/WikipediaIcon';
import { BlueskyIcon } from '@/components/icons/BlueskyIcon';
import { BitcoinTxPreview, BitcoinAddressPreview } from '@/components/BitcoinContentHeader';
import { CardsIcon } from '@/components/icons/CardsIcon';
import { extractYouTubeId, extractWikipediaTitle, extractWikidataId, extractBlueskyPost, extractGathererCard, type GathererCard } from '@/lib/linkEmbed';
import { GathererCardHeader } from '@/components/GathererCardHeader';
@@ -804,6 +805,10 @@ export function ExternalContentPreview({ identifier }: { identifier: string }) {
return <BookPreview isbn={content.value} link={link} />;
case 'iso3166':
return <CountryPreview code={content.code} link={link} />;
case 'bitcoin-tx':
return <BitcoinTxPreview txid={content.txid} link={link} />;
case 'bitcoin-address':
return <BitcoinAddressPreview address={content.address} link={link} />;
default:
return (
<Link to={link} className="block px-4 py-3 border-b border-border hover:bg-secondary/30 transition-colors">
+3 -6
View File
@@ -22,7 +22,6 @@ import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { getDisplayName } from '@/lib/getDisplayName';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { canZap } from '@/lib/canZap';
import { getEffectiveStreamStatus } from '@/lib/streamStatus';
import { cn } from '@/lib/utils';
@@ -351,11 +350,9 @@ function StreamAuthorRow({ event, participants }: { event: NostrEvent; participa
}
function ZapButton({ event }: { event: NostrEvent }) {
const author = useAuthor(event.pubkey);
const metadata = author.data?.metadata;
if (!canZap(metadata)) return null;
// ZapDialog handles the self-zap guard internally, so we only need to
// render the trigger. On-chain zaps are always available for any author;
// Lightning is an opt-in tab inside the dialog.
return (
<ZapDialog target={event}>
<Button variant="outline" size="icon" className="shrink-0 size-9 rounded-full text-amber-500 hover:text-amber-400 hover:bg-amber-500/10">
+1 -2
View File
@@ -19,7 +19,6 @@ import { useMuteList } from '@/hooks/useMuteList';
import { isEventMuted } from '@/lib/muteHelpers';
import { getDisplayName } from '@/lib/getDisplayName';
import { formatTime } from '@/lib/formatTime';
import { canZap } from '@/lib/canZap';
import { formatNumber } from '@/lib/formatNumber';
import { cn } from '@/lib/utils';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
@@ -188,7 +187,7 @@ function TrackDetail({ event }: { event: NostrEvent }) {
)}
</RepostMenu>
{user && canZap(metadata) && (
{user && user.pubkey !== event.pubkey && (
<ZapDialog target={event}>
<button
className="size-11 rounded-full bg-secondary/50 text-muted-foreground hover:bg-secondary flex items-center justify-center transition-colors"
+9 -3
View File
@@ -2,6 +2,7 @@ import type { NostrEvent } from "@nostrify/nostrify";
import {
Award,
Bird,
Bitcoin,
Camera,
Egg,
FileCode,
@@ -107,7 +108,6 @@ import { useProfileUrl } from "@/hooks/useProfileUrl";
import { useShareOrigin } from "@/hooks/useShareOrigin";
import { toast } from "@/hooks/useToast";
import { useEventStats } from "@/hooks/useTrending";
import { canZap } from "@/lib/canZap";
import { extractZapAmount, extractZapSender, extractZapMessage } from "@/hooks/useEventInteractions";
import { getContentWarning } from "@/lib/contentWarning";
import { genUserName } from "@/lib/genUserName";
@@ -366,8 +366,10 @@ export const NoteCard = memo(function NoteCard({
if (mapped) triggerBlobbiReaction(mapped);
}, [triggerBlobbiReaction]);
// Check if the current user can zap this event's author
const canZapAuthor = user && canZap(metadata);
// Zap button shows for any logged-in user except on their own posts.
// On-chain zaps are always available; Lightning is offered inside the dialog
// when the author has lud06/lud16.
const canZapAuthor = !!user && user.pubkey !== event.pubkey;
const { onClick: openPost, onAuxClick: auxOpenPost } = useOpenPost(
`/${encodedId}`,
@@ -1844,6 +1846,10 @@ const KIND_HEADER_MAP: Record<number, KindHeaderConfig> = {
icon: Zap,
action: "zapped",
},
8333: {
icon: Bitcoin,
action: "Bitcoin-zapped",
},
31124: {
icon: Egg,
action: (event) => publishedAtAction(event, { created: "created their", updated: "cared for their", fallback: "cared for their" }),
+350
View File
@@ -0,0 +1,350 @@
import { useState, useMemo, useCallback, useEffect } from 'react';
import { AlertTriangle, Zap, Gauge, Loader2, Bitcoin } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Textarea } from '@/components/ui/textarea';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useBitcoinSigner } from '@/hooks/useBitcoinSigner';
import { useOnchainZap, type OnchainFeeSpeed } from '@/hooks/useOnchainZap';
import { useNostrLogin } from '@nostrify/react/login';
import {
nostrPubkeyToBitcoinAddress,
fetchUTXOs,
fetchBtcPrice,
getFeeRates,
estimateFee,
isLargeAmount,
satsToUSD,
formatSats,
} from '@/lib/bitcoin';
import type { NostrEvent } from '@nostrify/nostrify';
const USD_PRESETS = [1, 5, 10, 25, 100];
const FEE_SPEED_LABELS: Record<OnchainFeeSpeed, string> = {
fastest: '~10 min',
halfHour: '~30 min',
hour: '~1 hour',
economy: '~1 day',
};
interface OnchainZapContentProps {
target: NostrEvent;
onSuccess?: () => void;
}
/**
* Bitcoin zap flow. Publishes a BTC transaction paying the target author's
* derived Taproot address, then publishes a kind 8333 event linking the tx
* to the target event.
*
* UX mirrors the Lightning zap flow: one screen, one button, no review step.
* Balance, fee breakdown, and confirmation are all hidden unless needed.
*/
export function OnchainZapContent({ target, onSuccess }: OnchainZapContentProps) {
const { user } = useCurrentUser();
const { capability } = useBitcoinSigner();
const { logins } = useNostrLogin();
const loginType = logins[0]?.type;
const [usdAmount, setUsdAmount] = useState<number | string>(5);
const [comment, setComment] = useState('');
const [feeSpeed, setFeeSpeed] = useState<OnchainFeeSpeed>('halfHour');
const [error, setError] = useState('');
const [feePopoverOpen, setFeePopoverOpen] = useState(false);
const senderAddress = user ? nostrPubkeyToBitcoinAddress(user.pubkey) : '';
const recipientAddress = useMemo(() => nostrPubkeyToBitcoinAddress(target.pubkey), [target.pubkey]);
const truncatedRecipient = recipientAddress
? `${recipientAddress.slice(0, 10)}${recipientAddress.slice(-8)}`
: '';
const { data: btcPrice } = useQuery({
queryKey: ['btc-price'],
queryFn: fetchBtcPrice,
staleTime: 30_000,
});
const { data: utxos } = useQuery({
queryKey: ['bitcoin-utxos', senderAddress],
queryFn: () => fetchUTXOs(senderAddress),
enabled: !!senderAddress,
staleTime: 30_000,
});
const { data: feeRates } = useQuery({
queryKey: ['bitcoin-fee-rates'],
queryFn: getFeeRates,
staleTime: 30_000,
});
const totalBalance = useMemo(() => utxos?.reduce((s, u) => s + u.value, 0) ?? 0, [utxos]);
const currentFeeRate = useMemo(() => {
if (!feeRates) return 0;
switch (feeSpeed) {
case 'fastest': return feeRates.fastestFee;
case 'halfHour': return feeRates.halfHourFee;
case 'hour': return feeRates.hourFee;
case 'economy': return feeRates.economyFee;
}
}, [feeRates, feeSpeed]);
// Convert the USD amount to sats
const amountSats = useMemo(() => {
if (!btcPrice) return 0;
const usd = typeof usdAmount === 'string' ? parseFloat(usdAmount) : usdAmount;
if (!Number.isFinite(usd) || usd <= 0) return 0;
const btc = usd / btcPrice;
return Math.round(btc * 100_000_000);
}, [usdAmount, btcPrice]);
const estimatedFeeSats = useMemo(() => {
if (!utxos?.length || !currentFeeRate || !amountSats) return 0;
const fee2 = estimateFee(utxos.length, 2, currentFeeRate);
const change = totalBalance - amountSats - fee2;
const numOutputs = change > 546 ? 2 : 1;
return estimateFee(utxos.length, numOutputs, currentFeeRate);
}, [utxos, currentFeeRate, amountSats, totalBalance]);
const totalSats = amountSats + estimatedFeeSats;
const insufficient = totalBalance > 0 && totalSats > totalBalance;
const showBalance = insufficient || (amountSats > 0 && totalBalance === 0);
// For large amounts, require a two-tap confirmation on the primary button.
// This catches fat-finger sends without nagging on normal amounts.
const isLarge = isLargeAmount(totalSats, btcPrice);
const [confirmArmed, setConfirmArmed] = useState(false);
// Re-arm (i.e. clear confirmation) whenever the amount, fee rate, or price
// moves — so editing after arming forces another deliberate click.
useEffect(() => {
setConfirmArmed(false);
}, [amountSats, currentFeeRate, btcPrice]);
const { zapAsync, isZapping, progress } = useOnchainZap(target, onSuccess);
const handleZap = useCallback(async () => {
setError('');
if (!user) { setError('You must be logged in.'); return; }
if (user.pubkey === target.pubkey) { setError("You can't zap yourself."); return; }
// `capability === 'unsupported'` is already handled by the UI replacement
// above; 'supported' and 'unknown' both proceed (the latter may fail at
// sign time, which will then flip the UI to the unsupported state).
if (!btcPrice) { setError('Waiting for BTC price…'); return; }
if (amountSats <= 0) { setError('Enter an amount.'); return; }
if (!utxos?.length) { setError("You don't have any Bitcoin yet. Receive some first."); return; }
if (insufficient) { setError('Not enough Bitcoin for this amount + network fee.'); return; }
// Two-tap safety for large amounts: first click arms, second click sends.
if (isLarge && !confirmArmed) {
setConfirmArmed(true);
return;
}
try {
await zapAsync({ amountSats, comment, feeSpeed });
// onSuccess (passed to useOnchainZap) closes the dialog; toast is shown by the hook.
} catch (err) {
// Capability errors flip the UI via `reportSignerUnsupported` in the
// hook's `onError`; no need to surface a form-level error for those.
const msg = err instanceof Error ? err.message : 'Zap failed';
const isCapability = /does not support|doesn't support|signpsbt|sign_psbt/i.test(msg);
if (!isCapability) setError(msg);
}
}, [user, target.pubkey, btcPrice, amountSats, utxos, insufficient, zapAsync, comment, feeSpeed, isLarge, confirmArmed]);
// ── Signer not supported ──────────────────────────────────────
if (user && capability === 'unsupported') {
// Tailor the hint to the login type so the user knows what to change
// to regain Bitcoin-zap capability.
const message =
loginType === 'extension'
? "Your browser extension doesn't support sending Bitcoin. Try a different extension, or log in with your secret key."
: loginType === 'bunker'
? "Your remote signer doesn't support sending Bitcoin. Update your signer, or log in with your secret key."
: "Log in with your secret key to send Bitcoin zaps.";
return (
<div className="px-4 py-6 flex flex-col items-center text-center gap-3">
<div className="size-12 rounded-full bg-muted flex items-center justify-center">
<AlertTriangle className="size-6 text-muted-foreground" />
</div>
<div className="space-y-1">
<p className="text-sm font-semibold">Bitcoin zaps aren't available</p>
<p className="text-xs text-muted-foreground max-w-xs">
{message}
</p>
</div>
</div>
);
}
const currentUsd = typeof usdAmount === 'string' ? parseFloat(usdAmount) : usdAmount;
return (
<div className="grid gap-3 px-4 py-4 w-full overflow-hidden">
{/* Amount presets (USD) */}
<ToggleGroup
type="single"
value={USD_PRESETS.includes(Number(usdAmount)) ? String(usdAmount) : ''}
onValueChange={(v) => { if (v) { setUsdAmount(Number(v)); setError(''); } }}
className="grid grid-cols-5 gap-1 w-full"
>
{USD_PRESETS.map((v) => (
<ToggleGroupItem
key={v}
value={String(v)}
className="flex flex-col h-auto min-w-0 text-xs px-1 py-2"
>
<span className="font-semibold">${v}</span>
</ToggleGroupItem>
))}
</ToggleGroup>
<div className="flex items-center gap-2">
<div className="h-px flex-1 bg-muted" />
<span className="text-xs text-muted-foreground">OR</span>
<div className="h-px flex-1 bg-muted" />
</div>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">$</span>
<Input
type="number"
inputMode="decimal"
min={0}
step="0.01"
placeholder="Custom amount (USD)"
value={usdAmount}
onChange={(e) => { setUsdAmount(e.target.value); setError(''); }}
className="pl-6"
/>
</div>
{/* Comment */}
<Textarea
placeholder="Add a comment (optional)"
value={comment}
onChange={(e) => setComment(e.target.value)}
rows={2}
className="resize-none"
/>
{/* Recipient Bitcoin address — always shown so users can verify the
derived destination before signing. */}
{recipientAddress && (
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5 min-w-0">
<Bitcoin className="size-3.5 text-orange-500 shrink-0" />
<span className="shrink-0">To:</span>
<span className="font-mono truncate" title={recipientAddress}>{truncatedRecipient}</span>
</div>
</div>
)}
{/* Fee line — click to open speed picker */}
{amountSats > 0 && (
<div className="flex items-center justify-between text-xs">
<Popover open={feePopoverOpen} onOpenChange={setFeePopoverOpen}>
<PopoverTrigger asChild>
<button
type="button"
className="inline-flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors"
>
<Gauge className="size-3.5" />
<span>
Fee{' '}
{estimatedFeeSats > 0 && btcPrice
? `≈ ${satsToUSD(estimatedFeeSats, btcPrice)}`
: ''}
<span className="opacity-60"> · {FEE_SPEED_LABELS[feeSpeed]}</span>
</span>
</button>
</PopoverTrigger>
<PopoverContent align="start" sideOffset={6} className="w-56 p-1">
<div className="flex flex-col">
{(Object.keys(FEE_SPEED_LABELS) as OnchainFeeSpeed[]).map((speed) => {
const rate = feeRates
? speed === 'fastest' ? feeRates.fastestFee
: speed === 'halfHour' ? feeRates.halfHourFee
: speed === 'hour' ? feeRates.hourFee
: feeRates.economyFee
: 0;
const selected = speed === feeSpeed;
return (
<button
key={speed}
type="button"
onClick={() => { setFeeSpeed(speed); setFeePopoverOpen(false); }}
className={`flex items-center justify-between px-2 py-1.5 rounded-sm text-xs text-left hover:bg-muted transition-colors ${selected ? 'bg-muted font-medium' : ''}`}
>
<span>{FEE_SPEED_LABELS[speed]}</span>
<span className="text-muted-foreground">{rate} sat/vB</span>
</button>
);
})}
</div>
</PopoverContent>
</Popover>
{showBalance && btcPrice && (
<span className="text-muted-foreground">
Balance: {satsToUSD(totalBalance, btcPrice)}
</span>
)}
</div>
)}
{/* Error */}
{error && (
<p className="text-xs text-destructive">{error}</p>
)}
<Button
onClick={handleZap}
disabled={!btcPrice || amountSats <= 0 || isZapping}
variant={isLarge && !isZapping ? 'destructive' : 'default'}
className="w-full"
>
{isZapping ? (
<>
<Loader2 className="size-4 mr-1.5 animate-spin" />
{progressLabel(progress)}
</>
) : isLarge && confirmArmed ? (
<>
<Zap className="size-4 mr-1.5" />
Tap again to send {currentUsd > 0 ? `$${currentUsd}` : ''}
</>
) : (
<>
<Zap className="size-4 mr-1.5" />
Zap {currentUsd > 0 ? `$${currentUsd}` : ''}
{amountSats > 0 && ` · ${formatSats(amountSats)} sats`}
</>
)}
</Button>
</div>
);
}
function progressLabel(progress: 'idle' | 'building' | 'signing' | 'broadcasting' | 'publishing'): string {
switch (progress) {
case 'building': return 'Building';
case 'signing': return 'Signing';
case 'broadcasting': return 'Broadcasting';
case 'publishing': return 'Publishing';
default: return 'Processing';
}
}
+1 -2
View File
@@ -23,7 +23,6 @@ import { useProfileUrl } from '@/hooks/useProfileUrl';
import { useEventStats } from '@/hooks/useTrending';
import { getDisplayName } from '@/lib/getDisplayName';
import { genUserName } from '@/lib/genUserName';
import { canZap } from '@/lib/canZap';
import { formatNumber } from '@/lib/formatNumber';
interface PhotoBottomBarProps {
@@ -40,7 +39,7 @@ export function PhotoBottomBar({ event }: PhotoBottomBarProps) {
const { data: stats } = useEventStats(event.id, event);
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
const [commentsOpen, setCommentsOpen] = useState(false);
const canZapAuthor = user && canZap(metadata);
const canZapAuthor = !!user && user.pubkey !== event.pubkey;
return (
<>
+1 -2
View File
@@ -18,7 +18,6 @@ import { useMuteList } from '@/hooks/useMuteList';
import { isEventMuted } from '@/lib/muteHelpers';
import { getDisplayName } from '@/lib/getDisplayName';
import { formatTime } from '@/lib/formatTime';
import { canZap } from '@/lib/canZap';
import { formatNumber } from '@/lib/formatNumber';
import { cn } from '@/lib/utils';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
@@ -184,7 +183,7 @@ function EpisodeDetail({ event }: { event: NostrEvent }) {
)}
</RepostMenu>
{user && canZap(metadata) && (
{user && user.pubkey !== event.pubkey && (
<ZapDialog target={event}>
<button
className="size-11 rounded-full bg-secondary/50 text-muted-foreground hover:bg-secondary flex items-center justify-center transition-colors"
+3 -5
View File
@@ -7,12 +7,10 @@ import { RepostIcon } from '@/components/icons/RepostIcon';
import { ReactionButton } from '@/components/ReactionButton';
import { RepostMenu } from '@/components/RepostMenu';
import { ZapDialog } from '@/components/ZapDialog';
import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useEventStats } from '@/hooks/useTrending';
import { useShareOrigin } from '@/hooks/useShareOrigin';
import { useToast } from '@/hooks/useToast';
import { canZap } from '@/lib/canZap';
import { formatNumber } from '@/lib/formatNumber';
import { shareOrCopy } from '@/lib/share';
import { cn } from '@/lib/utils';
@@ -43,9 +41,9 @@ export function PostActionBar({
const { toast } = useToast();
const { user } = useCurrentUser();
const shareOrigin = useShareOrigin();
const author = useAuthor(event.pubkey);
const metadata = author.data?.metadata;
const canZapAuthor = user && canZap(metadata);
// Zap button shows for any logged-in user except on their own posts.
// Both on-chain and Lightning zaps are supported inside the dialog.
const canZapAuthor = !!user && user.pubkey !== event.pubkey;
const { data: stats } = useEventStats(event.id, event);
const repostTotal = (stats?.reposts ?? 0) + (stats?.quotes ?? 0);
+630
View File
@@ -0,0 +1,630 @@
import { useState, useCallback, useMemo } from 'react';
import { Link } from 'react-router-dom';
import {
ArrowUpRight,
AlertTriangle,
Check,
ChevronLeft,
Loader2,
Send,
} from 'lucide-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Skeleton } from '@/components/ui/skeleton';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useBitcoinSigner } from '@/hooks/useBitcoinSigner';
import { useToast } from '@/hooks/useToast';
import {
nostrPubkeyToBitcoinAddress,
npubToBitcoinAddress,
validateBitcoinAddress,
fetchUTXOs,
getFeeRates,
buildUnsignedPsbt,
finalizePsbt,
broadcastTransaction,
estimateFee,
maxSendable,
btcToSats,
satsToUSD,
formatSats,
formatBTC,
isLargeAmount,
} from '@/lib/bitcoin';
import type { FeeRates, UTXO } from '@/lib/bitcoin';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type FeeSpeed = 'fastest' | 'halfHour' | 'hour' | 'economy';
type Step = 'form' | 'confirm' | 'success';
interface SendBitcoinDialogProps {
isOpen: boolean;
onClose: () => void;
/** BTC/USD price — passed from the parent to avoid a duplicate fetch. */
btcPrice?: number;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const FEE_SPEED_LABELS: Record<FeeSpeed, string> = {
fastest: 'Fastest (~10 min)',
halfHour: 'Half hour',
hour: 'One hour',
economy: 'Economy (~1 day)',
};
function feeRateForSpeed(rates: FeeRates, speed: FeeSpeed): number {
const map: Record<FeeSpeed, number> = {
fastest: rates.fastestFee,
halfHour: rates.halfHourFee,
hour: rates.hourFee,
economy: rates.economyFee,
};
return map[speed];
}
/** Resolve a recipient string to a Bitcoin address, or throw. */
function resolveRecipient(input: string): string {
const trimmed = input.trim();
if (trimmed.startsWith('npub1')) {
return npubToBitcoinAddress(trimmed);
}
if (validateBitcoinAddress(trimmed)) {
return trimmed;
}
throw new Error('Invalid recipient. Enter an npub or a Bitcoin address (bc1...).');
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function SendBitcoinDialog({ isOpen, onClose, btcPrice }: SendBitcoinDialogProps) {
const { user } = useCurrentUser();
const { canSignPsbt, signPsbt } = useBitcoinSigner();
const { toast } = useToast();
const queryClient = useQueryClient();
// Form state
const [recipient, setRecipient] = useState('');
const [amount, setAmount] = useState('');
const [feeSpeed, setFeeSpeed] = useState<FeeSpeed>('halfHour');
const [error, setError] = useState('');
// Multi-step state
const [step, setStep] = useState<Step>('form');
const [txId, setTxId] = useState('');
const [confirmedFee, setConfirmedFee] = useState(0);
const senderAddress = user ? nostrPubkeyToBitcoinAddress(user.pubkey) : '';
// ── Data fetching ──────────────────────────────────────────────
const { data: utxos, isLoading: isLoadingUtxos } = useQuery({
queryKey: ['bitcoin-utxos', senderAddress],
queryFn: () => fetchUTXOs(senderAddress),
enabled: !!senderAddress && isOpen,
staleTime: 30_000,
});
const { data: feeRates, isLoading: isLoadingFees } = useQuery({
queryKey: ['bitcoin-fee-rates'],
queryFn: getFeeRates,
enabled: isOpen,
staleTime: 30_000,
});
const totalBalance = useMemo(() => utxos?.reduce((s, u) => s + u.value, 0) ?? 0, [utxos]);
const currentFeeRate = feeRates ? feeRateForSpeed(feeRates, feeSpeed) : 0;
// ── Derived values for the confirm screen ──────────────────────
const parsedAmountSats = useMemo(() => {
const n = parseFloat(amount);
return isNaN(n) || n <= 0 ? 0 : btcToSats(n);
}, [amount]);
const resolvedRecipient = useMemo(() => {
try { return resolveRecipient(recipient); } catch { return ''; }
}, [recipient]);
const previewFee = useMemo(() => {
if (!utxos?.length || !currentFeeRate || !parsedAmountSats) return 0;
// Estimate with 2 outputs first, then check if change would be below dust
const fee2 = estimateFee(utxos.length, 2, currentFeeRate);
const change = totalBalance - parsedAmountSats - fee2;
const numOutputs = change > 546 ? 2 : 1;
return estimateFee(utxos.length, numOutputs, currentFeeRate);
}, [utxos, currentFeeRate, parsedAmountSats, totalBalance]);
// ── Send Max ───────────────────────────────────────────────────
const handleSendMax = useCallback(() => {
if (!utxos?.length || !currentFeeRate) return;
const max = maxSendable(totalBalance, utxos.length, currentFeeRate);
if (max <= 0) return;
setAmount(formatBTC(max));
setError('');
}, [utxos, currentFeeRate, totalBalance]);
// ── Send mutation ──────────────────────────────────────────────
const sendMutation = useMutation({
mutationFn: async () => {
if (!user || !canSignPsbt || !signPsbt) throw new Error("Your login doesn't support sending Bitcoin.");
if (!utxos?.length) throw new Error('No spendable Bitcoin available.');
if (!feeRates) throw new Error('Fee rates not loaded.');
const recipientAddress = resolveRecipient(recipient);
const amountSats = btcToSats(parseFloat(amount));
if (isNaN(amountSats) || amountSats <= 0) throw new Error('Invalid amount.');
const feeRate = feeRateForSpeed(feeRates, feeSpeed);
// 1. Build unsigned PSBT
const { psbtHex, fee } = buildUnsignedPsbt(
user.pubkey,
recipientAddress,
amountSats,
utxos,
feeRate,
);
// 2. Sign via the signer (local nsec, NIP-07 extension, or NIP-46 bunker)
const signedHex = await signPsbt(psbtHex);
// 3. Finalize and extract raw tx
const txHex = finalizePsbt(signedHex);
const id = await broadcastTransaction(txHex);
return { txId: id, fee };
},
onSuccess: ({ txId: id, fee }) => {
setTxId(id);
setConfirmedFee(fee);
setStep('success');
toast({ title: 'Transaction sent', description: `Fee: ${formatSats(fee)} sats` });
// Invalidate wallet data so balance updates
queryClient.invalidateQueries({ queryKey: ['bitcoin-wallet'] });
queryClient.invalidateQueries({ queryKey: ['bitcoin-utxos'] });
},
onError: (err: Error) => {
setError(err.message);
setStep('form');
toast({ title: 'Transaction failed', description: err.message, variant: 'destructive' });
},
});
// ── Navigation ─────────────────────────────────────────────────
const goToConfirm = () => {
setError('');
try {
resolveRecipient(recipient);
} catch (err) {
setError(err instanceof Error ? err.message : 'Invalid recipient');
return;
}
const sats = btcToSats(parseFloat(amount));
if (isNaN(sats) || sats <= 0) { setError('Enter a valid amount.'); return; }
if (sats + previewFee > totalBalance) { setError('Insufficient funds.'); return; }
setStep('confirm');
};
const handleClose = () => {
setRecipient('');
setAmount('');
setError('');
setTxId('');
setConfirmedFee(0);
setStep('form');
setFeeSpeed('halfHour');
onClose();
};
// ── Render ─────────────────────────────────────────────────────
// Signer doesn't support Bitcoin signing
if (isOpen && !canSignPsbt) {
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="size-5 text-orange-500" />
Sending Not Available
</DialogTitle>
<DialogDescription>
Your login doesn't support sending Bitcoin.
</DialogDescription>
</DialogHeader>
<Alert>
<AlertTriangle className="size-4" />
<AlertDescription>
Log in with your secret key to send Bitcoin.
</AlertDescription>
</Alert>
<Button onClick={handleClose}>Close</Button>
</DialogContent>
</Dialog>
);
}
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-md">
{step === 'success' ? (
<SuccessView txId={txId} fee={confirmedFee} btcPrice={btcPrice} onClose={handleClose} />
) : step === 'confirm' ? (
<ConfirmView
recipient={resolvedRecipient}
amountSats={parsedAmountSats}
fee={previewFee}
feeSpeed={feeSpeed}
btcPrice={btcPrice}
isPending={sendMutation.isPending}
onBack={() => setStep('form')}
onConfirm={() => sendMutation.mutate()}
/>
) : (
<FormView
recipient={recipient}
amount={amount}
feeSpeed={feeSpeed}
error={error}
totalBalance={totalBalance}
btcPrice={btcPrice}
utxos={utxos}
feeRates={feeRates}
isLoadingUtxos={isLoadingUtxos}
isLoadingFees={isLoadingFees}
currentFeeRate={currentFeeRate}
onRecipientChange={(v) => { setRecipient(v); setError(''); }}
onAmountChange={(v) => { setAmount(v); setError(''); }}
onFeeSpeedChange={setFeeSpeed}
onSendMax={handleSendMax}
onNext={goToConfirm}
onCancel={handleClose}
/>
)}
</DialogContent>
</Dialog>
);
}
// ═══════════════════════════════════════════════════════════════════
// Sub-views
// ═══════════════════════════════════════════════════════════════════
// ── Form ─────────────────────────────────────────────────────────
interface FormViewProps {
recipient: string;
amount: string;
feeSpeed: FeeSpeed;
error: string;
totalBalance: number;
btcPrice?: number;
utxos?: UTXO[];
feeRates?: FeeRates;
isLoadingUtxos: boolean;
isLoadingFees: boolean;
currentFeeRate: number;
onRecipientChange: (v: string) => void;
onAmountChange: (v: string) => void;
onFeeSpeedChange: (v: FeeSpeed) => void;
onSendMax: () => void;
onNext: () => void;
onCancel: () => void;
}
function FormView({
recipient, amount, feeSpeed, error, totalBalance, btcPrice,
feeRates, isLoadingUtxos, isLoadingFees, currentFeeRate,
onRecipientChange, onAmountChange, onFeeSpeedChange, onSendMax, onNext, onCancel,
}: FormViewProps) {
const parsedBtc = parseFloat(amount) || 0;
return (
<>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Send className="size-5 text-orange-500" />
Send Bitcoin
</DialogTitle>
<DialogDescription>
Send Bitcoin to a Nostr user or Bitcoin address
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Balance */}
<div className="rounded-lg bg-muted/50 p-4">
<Label className="text-xs text-muted-foreground">Available Balance</Label>
{isLoadingUtxos ? (
<Skeleton className="mt-1 h-7 w-36" />
) : (
<p className="text-xl font-bold">
{btcPrice
? satsToUSD(totalBalance, btcPrice)
: `${formatBTC(totalBalance)} BTC`}
</p>
)}
</div>
{/* Recipient */}
<div className="space-y-2">
<Label htmlFor="send-recipient">Recipient</Label>
<Input
id="send-recipient"
placeholder="npub1... or bc1..."
value={recipient}
onChange={(e) => onRecipientChange(e.target.value)}
/>
<p className="text-xs text-muted-foreground">Nostr npub or Bitcoin address</p>
</div>
{/* Amount */}
<div className="space-y-2">
<Label htmlFor="send-amount">Amount (BTC)</Label>
<Input
id="send-amount"
type="number"
step="0.00000001"
min="0"
placeholder="0.00000000"
value={amount}
onChange={(e) => onAmountChange(e.target.value)}
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>
{parsedBtc > 0
? btcPrice
? satsToUSD(btcToSats(parsedBtc), btcPrice)
: `${formatSats(btcToSats(parsedBtc))} sats`
: ''}
</span>
<button
type="button"
onClick={onSendMax}
className="text-primary hover:underline cursor-pointer"
>
Send Max
</button>
</div>
</div>
{/* Fee speed */}
<div className="space-y-2">
<Label>Transaction Speed</Label>
<Select value={feeSpeed} onValueChange={(v) => onFeeSpeedChange(v as FeeSpeed)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(FEE_SPEED_LABELS) as FeeSpeed[]).map((speed) => (
<SelectItem key={speed} value={speed}>
{FEE_SPEED_LABELS[speed]}
{' '}
{isLoadingFees ? '...' : feeRates ? `${feeRateForSpeed(feeRates, speed)} sat/vB` : ''}
</SelectItem>
))}
</SelectContent>
</Select>
{currentFeeRate > 0 && parsedBtc > 0 && (
<p className="text-xs text-muted-foreground">
Estimated fee: ~{formatSats(estimateFee(1, 2, currentFeeRate))} sats
</p>
)}
</div>
{/* Error */}
{error && (
<Alert variant="destructive">
<AlertTriangle className="size-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{/* Warning */}
<Alert>
<AlertTriangle className="size-4" />
<AlertDescription className="text-xs">
<strong>Warning:</strong> This is an experimental feature. Test with small amounts first.
Transactions cannot be reversed.
</AlertDescription>
</Alert>
{/* Actions */}
<div className="flex gap-2">
<Button variant="outline" onClick={onCancel} className="flex-1">Cancel</Button>
<Button
onClick={onNext}
disabled={!recipient || !amount || parsedBtc <= 0 || isLoadingUtxos || isLoadingFees}
className="flex-1"
>
<ArrowUpRight className="size-4 mr-1.5" />
Review
</Button>
</div>
</div>
</>
);
}
// ── Confirm ──────────────────────────────────────────────────────
interface ConfirmViewProps {
recipient: string;
amountSats: number;
fee: number;
feeSpeed: FeeSpeed;
btcPrice?: number;
isPending: boolean;
onBack: () => void;
onConfirm: () => void;
}
function ConfirmView({ recipient, amountSats, fee, feeSpeed, btcPrice, isPending, onBack, onConfirm }: ConfirmViewProps) {
const totalSats = amountSats + fee;
const isLarge = isLargeAmount(totalSats, btcPrice);
const row = (label: string, sats: number) => (
<div className="flex justify-between items-baseline">
<span className="text-sm text-muted-foreground">{label}</span>
<div className="text-right">
<span className="text-sm font-medium">
{formatBTC(sats)} BTC
</span>
{btcPrice && (
<span className="text-xs text-muted-foreground ml-2">
({satsToUSD(sats, btcPrice)})
</span>
)}
</div>
</div>
);
const truncatedRecipient = recipient.length > 24
? `${recipient.slice(0, 12)}...${recipient.slice(-8)}`
: recipient;
return (
<>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Send className="size-5 text-orange-500" />
Confirm Transaction
</DialogTitle>
<DialogDescription>
Review the details before sending
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Recipient */}
<div className="rounded-lg bg-muted/50 p-4 space-y-1">
<Label className="text-xs text-muted-foreground">Sending to</Label>
<p className="text-sm font-mono break-all">{truncatedRecipient}</p>
</div>
{/* Breakdown */}
<div className="space-y-2">
{row('Amount', amountSats)}
{row(`Fee (${FEE_SPEED_LABELS[feeSpeed].toLowerCase()})`, fee)}
<div className="border-t pt-2">
{row('Total', totalSats)}
</div>
</div>
{/* Large-amount notice — informational, not alarmist. */}
{isLarge && btcPrice && (
<p className="text-xs text-muted-foreground text-center">
Sending {satsToUSD(totalSats, btcPrice)} — double-check the recipient and amount.
</p>
)}
{/* Actions */}
<div className="flex gap-2">
<Button variant="outline" onClick={onBack} disabled={isPending} className="flex-1">
<ChevronLeft className="size-4 mr-1" />
Back
</Button>
<Button
onClick={onConfirm}
disabled={isPending}
variant={isLarge && !isPending ? 'destructive' : 'default'}
className="flex-1"
>
{isPending ? (
<>
<Loader2 className="size-4 mr-1.5 animate-spin" />
Sending...
</>
) : (
<>
<Send className="size-4 mr-1.5" />
Confirm &amp; Send
</>
)}
</Button>
</div>
</div>
</>
);
}
// ── Success ──────────────────────────────────────────────────────
interface SuccessViewProps {
txId: string;
fee: number;
btcPrice?: number;
onClose: () => void;
}
function SuccessView({ txId, fee, btcPrice, onClose }: SuccessViewProps) {
return (
<>
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-green-600 dark:text-green-400">
<Check className="size-5" />
Transaction Sent
</DialogTitle>
<DialogDescription>
Your transaction has been broadcast to the Bitcoin network.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="rounded-lg bg-green-50 dark:bg-green-950/30 p-4 space-y-1">
<Label className="text-xs text-green-700 dark:text-green-300">Transaction ID</Label>
<p className="text-xs font-mono break-all text-green-900 dark:text-green-100">{txId}</p>
</div>
<p className="text-xs text-muted-foreground text-center">
Fee: {formatSats(fee)} sats
{btcPrice ? ` (${satsToUSD(fee, btcPrice)})` : ''}
</p>
<div className="flex gap-2">
<Button variant="outline" className="flex-1" asChild>
<Link to={`/i/bitcoin:tx:${txId}`} onClick={onClose}>
View Details
</Link>
</Button>
<Button className="flex-1" onClick={onClose}>
Done
</Button>
</div>
</div>
</>
);
}
+68 -10
View File
@@ -1,5 +1,5 @@
import { useState, useEffect, useRef, forwardRef } from 'react';
import { Zap, Copy, Check, ExternalLink, Sparkle, Sparkles, Star, Rocket, X, Smile } from 'lucide-react';
import { Zap, Copy, Check, ExternalLink, Sparkle, Sparkles, Star, Rocket, X, Smile, Bitcoin } from 'lucide-react';
import { openUrl } from '@/lib/downloadFile';
import { impactMedium } from '@/lib/haptics';
import { HelpTip } from '@/components/HelpTip';
@@ -15,12 +15,15 @@ import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Card, CardContent } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { EmojiPicker } from '@/components/EmojiPicker';
import { EmojiShortcodeAutocomplete } from '@/components/EmojiShortcodeAutocomplete';
import { OnchainZapContent } from '@/components/OnchainZapContent';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
import { useBitcoinSigner } from '@/hooks/useBitcoinSigner';
import { useToast } from '@/hooks/useToast';
import { useZaps } from '@/hooks/useZaps';
import { useWallet } from '@/hooks/useWallet';
@@ -28,6 +31,7 @@ import { useAppContext } from '@/hooks/useAppContext';
import { useCustomEmojis } from '@/hooks/useCustomEmojis';
import { useFeedSettings } from '@/hooks/useFeedSettings';
import { useInsertText } from '@/hooks/useInsertText';
import { canZap } from '@/lib/canZap';
import type { Event } from 'nostr-tools';
import QRCode from 'qrcode';
import type { WebLNProvider } from "@webbtc/webln-types";
@@ -46,7 +50,7 @@ const presetAmounts = [
{ amount: 1000, icon: Rocket },
];
interface ZapContentProps {
interface LightningZapContentProps {
invoice: string | null;
amount: number | string;
comment: string;
@@ -67,8 +71,8 @@ interface ZapContentProps {
zap: (amount: number, comment: string) => void;
}
// Moved ZapContent outside of ZapDialog to prevent re-renders causing focus loss
const ZapContent = forwardRef<HTMLDivElement, ZapContentProps>(({
// Forwarded ref + defined outside ZapDialog to prevent re-render focus loss.
const LightningZapContent = forwardRef<HTMLDivElement, LightningZapContentProps>(({
invoice,
amount,
comment,
@@ -271,7 +275,7 @@ const ZapContent = forwardRef<HTMLDivElement, ZapContentProps>(({
)}
</div>
));
ZapContent.displayName = 'ZapContent';
LightningZapContent.displayName = 'LightningZapContent';
export function ZapDialog({ target, children, className }: ZapDialogProps) {
const [open, setOpen] = useState(false);
@@ -292,6 +296,17 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
const customEmojis = feedSettings.showCustomEmojis !== false ? allCustomEmojis : [];
const { insertAtCursor, insertEmoji } = useInsertText(commentTextareaRef, comment, setComment);
// Default tab: Bitcoin. Users can switch to Lightning if available.
// If the user's signer can't sign PSBTs AND Lightning is available, we
// transparently default to Lightning instead of showing an unusable
// Bitcoin tab as the primary option.
const { capability: btcCapability } = useBitcoinSigner();
const hasLightning = canZap(author?.metadata);
const bitcoinUnsupported = btcCapability === 'unsupported';
const [activeTab, setActiveTab] = useState<'onchain' | 'lightning'>(
bitcoinUnsupported && hasLightning ? 'lightning' : 'onchain',
);
useEffect(() => {
if (target) {
setComment(`Zapped with ${config.appName}!`);
@@ -360,21 +375,36 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
setInvoice(null);
setCopied(false);
setQrCodeUrl('');
setActiveTab(bitcoinUnsupported && hasLightning ? 'lightning' : 'onchain');
} else {
setAmount(100);
setInvoice(null);
setCopied(false);
setQrCodeUrl('');
}
// `bitcoinUnsupported`/`hasLightning` deliberately excluded — we only
// want to reset the active tab on open/close, not on every capability
// re-render. The mid-session flip is handled by the effect below.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, setInvoice]);
// If Bitcoin capability flips to `unsupported` while the dialog is open
// (e.g. a bunker just rejected `sign_psbt`) and Lightning is available,
// transparently switch to the Lightning tab. Otherwise the user would be
// stuck staring at the "Bitcoin zaps aren't available" panel.
useEffect(() => {
if (open && bitcoinUnsupported && hasLightning && activeTab === 'onchain') {
setActiveTab('lightning');
}
}, [open, bitcoinUnsupported, hasLightning, activeTab]);
const handleZap = () => {
impactMedium();
const finalAmount = typeof amount === 'string' ? parseInt(amount, 10) : amount;
zap(finalAmount, comment);
};
const contentProps = {
const lightningContentProps = {
invoice,
amount,
comment,
@@ -395,9 +425,12 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
zap,
};
const canZap = !!user && user.pubkey !== target.pubkey && !!(author?.metadata?.lud06 || author?.metadata?.lud16);
// Zap button shows for any logged-in user except when targeting oneself.
// On-chain is always available; Lightning is offered as an in-dialog option
// when the author has a Lightning address.
const canOpenZap = !!user && user.pubkey !== target.pubkey;
if (!canZap) {
if (!canOpenZap) {
return <>{children}</>;
}
@@ -421,10 +454,35 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
</button>
</div>
<p className="px-4 -mt-1 mb-1 text-sm text-muted-foreground">
{invoice ? 'Pay with Bitcoin Lightning Network' : 'Send a small Bitcoin payment to support the creator.'}
{invoice
? 'Pay with Bitcoin Lightning Network'
: activeTab === 'onchain'
? 'Send Bitcoin to support the creator.'
: 'Send a Lightning payment to support the creator.'}
</p>
<div className="overflow-y-auto">
<ZapContent {...contentProps} />
{hasLightning ? (
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'onchain' | 'lightning')} className="w-full">
<div className="px-4 pt-2">
<TabsList className="grid w-full grid-cols-2 h-9">
<TabsTrigger value="onchain" className="gap-1.5 text-xs">
<Bitcoin className="size-3.5" /> Bitcoin
</TabsTrigger>
<TabsTrigger value="lightning" className="gap-1.5 text-xs">
<Zap className="size-3.5" /> Lightning
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="onchain" className="mt-0">
<OnchainZapContent target={target} onSuccess={() => setOpen(false)} />
</TabsContent>
<TabsContent value="lightning" className="mt-0">
<LightningZapContent {...lightningContentProps} />
</TabsContent>
</Tabs>
) : (
<OnchainZapContent target={target} onSuccess={() => setOpen(false)} />
)}
</div>
</DialogContent>
</Dialog>
+25
View File
@@ -0,0 +1,25 @@
import { useQuery } from '@tanstack/react-query';
import { fetchAddressDetail, fetchBtcPrice } from '@/lib/bitcoin';
/**
* Fetch full address details (balance + recent txs) via the mempool.space API.
* Also fetches the current BTC/USD price for display.
*/
export function useBitcoinAddress(address: string) {
const { data: addressDetail, isLoading, error, refetch } = useQuery({
queryKey: ['bitcoin-address-detail', address],
queryFn: () => fetchAddressDetail(address),
enabled: !!address,
refetchInterval: 30_000,
});
const { data: btcPrice } = useQuery({
queryKey: ['btc-price'],
queryFn: fetchBtcPrice,
refetchInterval: 60_000,
staleTime: 30_000,
});
return { addressDetail, btcPrice, isLoading, error, refetch };
}
+201
View File
@@ -0,0 +1,201 @@
import { useEffect, useMemo, useState } from 'react';
import { useNostrLogin } from '@nostrify/react/login';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { type BtcSigner, hasBtcSigning } from '@/lib/bitcoin-signers';
/**
* Three possible states for Bitcoin PSBT signing capability:
*
* - `supported` — Known to work (nsec login, or extension that exposes `signPsbt`).
* - `unsupported` — Known not to work (extension without `signPsbt`, or a remote
* signer that has already rejected a `sign_psbt` request).
* - `unknown` — Cannot be determined in advance. Applies to NIP-46 bunker
* logins, since NIP-46 has no standard capability-discovery
* RPC. Treat as "attempt, then propagate the error if it
* fails" — the UI should allow the attempt but fall back to
* the unsupported state when a `sign_psbt` call rejects with
* a capability error (see `reportSignerUnsupported`).
*/
export type BitcoinSignerCapability = 'supported' | 'unsupported' | 'unknown';
/**
* Module-level registry of bunker pubkeys that have been observed to reject
* `sign_psbt`. Persists for the lifetime of the page so that the user doesn't
* see the "attempt then fail" path twice for the same bunker.
*/
const knownUnsupportedBunkers = new Set<string>();
/**
* Mark a bunker (keyed by user pubkey) as known-unsupported for PSBT signing.
* Subsequent renders of `useBitcoinSigner` will return `'unsupported'` for
* that user.
*/
export function reportSignerUnsupported(pubkey: string): void {
knownUnsupportedBunkers.add(pubkey);
// Dispatch a DOM event so hook consumers can re-render without plumbing a
// shared store through the app. Listened to by `useBitcoinSigner`.
window.dispatchEvent(new CustomEvent('bitcoin-signer-unsupported', { detail: pubkey }));
}
/**
* Clear the unsupported-bunker memo for a pubkey (or all pubkeys). Called
* when the user logs out or switches accounts, so that a re-login with a
* potentially-upgraded bunker doesn't inherit the previous rejection.
*/
export function clearSignerUnsupported(pubkey?: string): void {
if (pubkey === undefined) {
knownUnsupportedBunkers.clear();
} else {
knownUnsupportedBunkers.delete(pubkey);
}
window.dispatchEvent(new CustomEvent('bitcoin-signer-cleared', { detail: pubkey ?? '*' }));
}
/**
* Hook that exposes Bitcoin PSBT signing capability for the current login.
*
* Capability is probed eagerly for known login types so that the UI can
* replace itself with an "unsupported" state before the user attempts to
* sign anything (rather than surfacing a toast after the fact).
*
* - **nsec** → always `'supported'` (local signing).
* - **extension** → probes `window.nostr.signPsbt`. Returns `'supported'` if
* present, `'unsupported'` if absent, or `'unknown'` while
* still waiting for the extension to inject `window.nostr`.
* - **bunker** → `'unknown'` by default (NIP-46 has no capability RPC).
* Flips to `'unsupported'` for the session once a
* `sign_psbt` attempt has rejected with a capability error
* (see `reportSignerUnsupported`).
*/
export function useBitcoinSigner() {
const { user } = useCurrentUser();
const { logins } = useNostrLogin();
const loginType = logins[0]?.type;
// ── Extension: probe window.nostr.signPsbt ───────────────────
const [extensionProbe, setExtensionProbe] = useState<BitcoinSignerCapability>(() => {
if (loginType !== 'extension') return 'unknown';
const n = (globalThis as { nostr?: Record<string, unknown> }).nostr;
if (n && typeof n.signPsbt === 'function') return 'supported';
if (n) return 'unsupported';
return 'unknown';
});
useEffect(() => {
if (loginType !== 'extension') return;
// Re-probe periodically in case the extension injects `window.nostr` late.
let cancelled = false;
const probe = () => {
const n = (globalThis as { nostr?: Record<string, unknown> }).nostr;
if (!n) return false;
setExtensionProbe(typeof n.signPsbt === 'function' ? 'supported' : 'unsupported');
return true;
};
if (probe()) return;
const interval = setInterval(() => {
if (cancelled) return;
if (probe()) clearInterval(interval);
}, 250);
// Stop polling after 3 s — if the extension hasn't shown up by then it
// likely isn't going to.
const stop = setTimeout(() => clearInterval(interval), 3000);
return () => { cancelled = true; clearInterval(interval); clearTimeout(stop); };
}, [loginType]);
// ── Bunker: listen for capability-failure events ─────────────
const [bunkerUnsupported, setBunkerUnsupported] = useState(() =>
user ? knownUnsupportedBunkers.has(user.pubkey) : false,
);
// Reset memoised state whenever the active user changes (login/logout/
// account switch) so a fresh login with a potentially-upgraded signer
// isn't permanently tainted by a previous session's rejection. On full
// logout we also clear the module-level registry entirely, so logging back
// in lets the user try their bunker again.
useEffect(() => {
if (!user) {
setBunkerUnsupported(false);
knownUnsupportedBunkers.clear();
return;
}
setBunkerUnsupported(knownUnsupportedBunkers.has(user.pubkey));
}, [user?.pubkey]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (loginType !== 'bunker' || !user) return;
const onUnsupported = (e: Event) => {
const detail = (e as CustomEvent<string>).detail;
if (detail === user.pubkey) setBunkerUnsupported(true);
};
const onCleared = (e: Event) => {
const detail = (e as CustomEvent<string>).detail;
if (detail === '*' || detail === user.pubkey) setBunkerUnsupported(false);
};
window.addEventListener('bitcoin-signer-unsupported', onUnsupported);
window.addEventListener('bitcoin-signer-cleared', onCleared);
return () => {
window.removeEventListener('bitcoin-signer-unsupported', onUnsupported);
window.removeEventListener('bitcoin-signer-cleared', onCleared);
};
}, [loginType, user]);
// ── Aggregate capability ─────────────────────────────────────
const capability: BitcoinSignerCapability = useMemo(() => {
if (!user) return 'unsupported';
switch (loginType) {
case 'nsec':
// Local signing is always available for nsec logins.
return 'supported';
case 'extension':
return extensionProbe;
case 'bunker':
return bunkerUnsupported ? 'unsupported' : 'unknown';
default:
// Unknown login type: fall back to the structural check.
return hasBtcSigning(user.signer) ? 'unknown' : 'unsupported';
}
}, [user, loginType, extensionProbe, bunkerUnsupported]);
const btcSigner = useMemo((): BtcSigner | null => {
if (!user || capability === 'unsupported') return null;
if (hasBtcSigning(user.signer)) return user.signer;
return null;
}, [user, capability]);
return {
/** Detailed capability state. See {@link BitcoinSignerCapability}. */
capability,
/** True when capability is `'supported'` or `'unknown'` (attempt allowed). */
canSignPsbt: capability !== 'unsupported' && btcSigner !== null,
/**
* Sign a hex-encoded PSBT. Throws if the signer doesn't support it.
* The returned hex is a signed (but not finalized) PSBT.
*/
signPsbt: btcSigner
? (psbtHex: string) => btcSigner.signPsbt(psbtHex)
: null,
};
}
/**
* Classify a signer error as a "capability error" (the signer fundamentally
* cannot sign PSBTs) versus a transient/operational error (network blip,
* user cancellation, malformed PSBT, etc.).
*
* Used by `useOnchainZap` to decide whether a failed send should flip the
* UI into the `'unsupported'` state or just surface a normal error toast.
*/
export function isSignerCapabilityError(err: unknown): boolean {
if (!err) return false;
const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();
return (
msg.includes('does not support') ||
msg.includes("doesn't support") ||
msg.includes('signpsbt') ||
msg.includes('sign_psbt')
);
}
+25
View File
@@ -0,0 +1,25 @@
import { useQuery } from '@tanstack/react-query';
import { fetchTxDetail, fetchBtcPrice } from '@/lib/bitcoin';
/**
* Fetch full transaction details for a Bitcoin txid via the mempool.space API.
* Also fetches the current BTC/USD price for display.
*/
export function useBitcoinTx(txid: string) {
const { data: tx, isLoading, error } = useQuery({
queryKey: ['bitcoin-tx-detail', txid],
queryFn: () => fetchTxDetail(txid),
enabled: !!txid,
staleTime: 60_000,
});
const { data: btcPrice } = useQuery({
queryKey: ['btc-price'],
queryFn: fetchBtcPrice,
refetchInterval: 60_000,
staleTime: 30_000,
});
return { tx, btcPrice, isLoading, error };
}
+71
View File
@@ -0,0 +1,71 @@
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { nostrPubkeyToBitcoinAddress, fetchAddressData, fetchBtcPrice, fetchTransactions } from '@/lib/bitcoin';
/**
* Hook that derives a Bitcoin Taproot address from the current user's Nostr
* pubkey and fetches the on-chain balance from the Blockstream API.
*
* Balance auto-refreshes every 30 seconds while the component is mounted.
* BTC/USD price refreshes every 60 seconds.
*/
export function useBitcoinWallet() {
const { user } = useCurrentUser();
const bitcoinAddress = useMemo(() => {
if (!user) return '';
return nostrPubkeyToBitcoinAddress(user.pubkey);
}, [user]);
const {
data: addressData,
isLoading,
error,
refetch,
} = useQuery({
queryKey: ['bitcoin-balance', bitcoinAddress],
queryFn: () => fetchAddressData(bitcoinAddress),
enabled: !!bitcoinAddress,
refetchInterval: 30_000,
});
const { data: btcPrice } = useQuery({
queryKey: ['btc-price'],
queryFn: fetchBtcPrice,
refetchInterval: 60_000,
staleTime: 30_000,
});
const {
data: transactions,
isLoading: isLoadingTxs,
} = useQuery({
queryKey: ['bitcoin-txs', bitcoinAddress],
queryFn: () => fetchTransactions(bitcoinAddress),
enabled: !!bitcoinAddress,
refetchInterval: 30_000,
});
return {
/** The derived bc1p... Taproot address. */
bitcoinAddress,
/** Balance and transaction data (undefined while loading). */
addressData,
/** Current BTC price in USD. */
btcPrice,
/** Transaction history for the address. */
transactions,
/** Whether the initial balance fetch is in progress. */
isLoading,
/** Whether transactions are still loading. */
isLoadingTxs,
/** Error from the balance query, if any. */
error,
/** Manually trigger a balance refresh. */
refetch,
/** The current user's hex pubkey (convenience). */
pubkey: user?.pubkey ?? '',
};
}
+25 -8
View File
@@ -1,10 +1,12 @@
import { useNostr } from '@nostrify/react';
import { type NLoginType, NUser, useNostrLogin } from '@nostrify/react/login';
import { NRelay1 } from '@nostrify/nostrify';
import { NRelay1, NSecSigner } from '@nostrify/nostrify';
import { nip19 } from 'nostr-tools';
import { useCallback, useMemo } from 'react';
import { useAuthor } from './useAuthor.ts';
import { signerWithNudge } from '@/lib/signerWithNudge';
import { NSecSignerBtc, NBrowserSignerBtc, NConnectSignerBtc } from '@/lib/bitcoin-signers';
export function useCurrentUser() {
const { nostr } = useNostr();
@@ -15,23 +17,38 @@ export function useCurrentUser() {
let isBunkerConnected: (() => boolean) | undefined;
switch (login.type) {
case 'nsec': // Nostr login with secret key
user = NUser.fromNsecLogin(login);
case 'nsec': { // Nostr login with secret key — use BTC-extended signer
const sk = nip19.decode(login.data.nsec) as { type: 'nsec'; data: Uint8Array };
user = new NUser(login.type, login.pubkey, new NSecSignerBtc(sk.data));
break;
case 'bunker': { // Nostr login with NIP-46 "bunker://" URI
user = NUser.fromBunkerLogin(login, nostr);
}
case 'bunker': { // Nostr login with NIP-46 "bunker://" URI — use BTC-extended signer
const clientSk = nip19.decode(login.data.clientNsec) as { type: 'nsec'; data: Uint8Array };
const clientSigner = new NSecSigner(clientSk.data);
const bunkerRelays = login.data.relays;
user = new NUser(
login.type,
login.pubkey,
new NConnectSignerBtc({
relay: nostr.group(bunkerRelays),
pubkey: login.data.bunkerPubkey,
signer: clientSigner,
timeout: 60_000,
}),
);
// Called at nudge time to check whether any of the bunker's relay
// WebSockets are OPEN. Relay instances are shared with the main pool
// so pool.relays will contain them once they have been opened.
const bunkerRelays = (login as Extract<NLoginType, { type: 'bunker' }>).data.relays;
isBunkerConnected = () => bunkerRelays.some((url) => {
const relay = nostr.relay(url);
return relay instanceof NRelay1 && relay.socket.readyState === WebSocket.OPEN;
});
break;
}
case 'extension': // Nostr login with NIP-07 browser extension
user = NUser.fromExtensionLogin(login);
case 'extension': // Nostr login with NIP-07 browser extension — use BTC-extended signer
user = new NUser(login.type, login.pubkey, new NBrowserSignerBtc());
break;
// Other login types can be defined here
default:
+210
View File
@@ -0,0 +1,210 @@
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useBitcoinSigner, isSignerCapabilityError, reportSignerUnsupported } from '@/hooks/useBitcoinSigner';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useToast } from '@/hooks/useToast';
import { notificationSuccess } from '@/lib/haptics';
import {
nostrPubkeyToBitcoinAddress,
fetchUTXOs,
getFeeRates,
buildUnsignedPsbt,
finalizePsbt,
broadcastTransaction,
estimateFee,
} from '@/lib/bitcoin';
import type { FeeRates } from '@/lib/bitcoin';
export type OnchainFeeSpeed = 'fastest' | 'halfHour' | 'hour' | 'economy';
/**
* Resolves the fee rate for a given speed preset from a FeeRates bundle.
*/
function feeRateForSpeed(rates: FeeRates, speed: OnchainFeeSpeed): number {
switch (speed) {
case 'fastest': return rates.fastestFee;
case 'halfHour': return rates.halfHourFee;
case 'hour': return rates.hourFee;
case 'economy': return rates.economyFee;
}
}
interface OnchainZapArgs {
/** Amount to zap in satoshis. */
amountSats: number;
/** Optional comment to include in the kind 8333 event content. */
comment?: string;
/** Fee speed preset. Defaults to "halfHour". */
feeSpeed?: OnchainFeeSpeed;
}
interface OnchainZapResult {
/** The broadcast Bitcoin transaction ID. */
txid: string;
/** Fee paid in satoshis. */
fee: number;
/** The published kind 8333 event. */
event: NostrEvent;
}
/**
* Hook for sending on-chain (Bitcoin L1) zaps to a Nostr event or profile.
*
* Flow:
* 1. Build, sign, and broadcast a Bitcoin transaction paying the target
* author's derived Taproot address.
* 2. Publish a kind 8333 "onchain zap" event referencing the txid, the
* target event (`e` or `a` tag), and the recipient's pubkey.
*
* Unlike NIP-57 Lightning zaps, this works for *any* Nostr user — there is
* no LNURL dependency because every pubkey has a derived Taproot address.
*/
export function useOnchainZap(
target: NostrEvent,
onSuccess?: () => void,
) {
const { user } = useCurrentUser();
const { canSignPsbt, signPsbt } = useBitcoinSigner();
const { mutateAsync: publishEvent } = useNostrPublish();
const { toast } = useToast();
const queryClient = useQueryClient();
const [isZapping, setIsZapping] = useState(false);
const [progress, setProgress] = useState<'idle' | 'building' | 'signing' | 'broadcasting' | 'publishing'>('idle');
const mutation = useMutation<OnchainZapResult, Error, OnchainZapArgs>({
mutationFn: async ({ amountSats, comment = '', feeSpeed = 'halfHour' }) => {
if (!user) throw new Error('You must be logged in to zap.');
if (user.pubkey === target.pubkey) throw new Error("You can't zap yourself.");
if (!canSignPsbt || !signPsbt) {
throw new Error(
"Your login doesn't support sending Bitcoin. Log in with your secret key to send Bitcoin zaps.",
);
}
if (!Number.isFinite(amountSats) || amountSats <= 0) {
throw new Error('Invalid amount.');
}
setIsZapping(true);
setProgress('building');
const senderAddress = nostrPubkeyToBitcoinAddress(user.pubkey);
const recipientAddress = nostrPubkeyToBitcoinAddress(target.pubkey);
if (!senderAddress || !recipientAddress) {
throw new Error('Failed to derive Bitcoin address.');
}
// Fetch UTXOs and fee rates
const [utxos, rates] = await Promise.all([
fetchUTXOs(senderAddress),
getFeeRates(),
]);
if (utxos.length === 0) {
throw new Error('Your Bitcoin wallet has no spendable funds.');
}
const feeRate = feeRateForSpeed(rates, feeSpeed);
const totalBalance = utxos.reduce((s, u) => s + u.value, 0);
const estFee = estimateFee(utxos.length, 2, feeRate);
if (amountSats + estFee > totalBalance) {
throw new Error(
`Insufficient funds. Need ~${(amountSats + estFee).toLocaleString()} sats, have ${totalBalance.toLocaleString()}.`,
);
}
// Build unsigned PSBT
const { psbtHex, fee } = buildUnsignedPsbt(
user.pubkey,
recipientAddress,
amountSats,
utxos,
feeRate,
);
// Sign
setProgress('signing');
const signedHex = await signPsbt(psbtHex);
const txHex = finalizePsbt(signedHex);
// Broadcast
setProgress('broadcasting');
const txid = await broadcastTransaction(txHex);
// Publish kind 8333 event
setProgress('publishing');
const isAddressable = target.kind >= 30000 && target.kind < 40000;
const tags: string[][] = [
['i', `bitcoin:tx:${txid}`],
['p', target.pubkey],
['amount', String(amountSats)],
];
if (isAddressable) {
const dTag = target.tags.find(([n]) => n === 'd')?.[1] ?? '';
tags.push(['a', `${target.kind}:${target.pubkey}:${dTag}`]);
}
// Always include `e` for a concrete event reference (even for addressable events)
tags.push(['e', target.id]);
tags.push(['alt', `Bitcoin zap: ${amountSats.toLocaleString()} sats`]);
const event = await publishEvent({
kind: 8333,
content: comment,
tags,
});
return { txid, fee, event };
},
onSuccess: ({ txid, fee }) => {
notificationSuccess();
toast({
title: 'Bitcoin zap sent!',
description: `Broadcast txid ${txid.slice(0, 12)}… (fee ${fee.toLocaleString()} sats)`,
});
// Invalidate caches that track zaps / balances
queryClient.invalidateQueries({ queryKey: ['onchain-zaps'] });
queryClient.invalidateQueries({ queryKey: ['event-interactions'] });
queryClient.invalidateQueries({ queryKey: ['bitcoin-utxos'] });
queryClient.invalidateQueries({ queryKey: ['bitcoin-balance'] });
queryClient.invalidateQueries({ queryKey: ['bitcoin-txs'] });
onSuccess?.();
},
onError: (err) => {
// If the signer turned out to not support PSBT signing (common for
// NIP-46 bunkers where capability can't be probed up front), mark the
// signer as unsupported for the rest of the session. The dialog UI
// watches this state and replaces itself with an "unsupported" panel
// instead of relying on this toast.
if (isSignerCapabilityError(err) && user) {
reportSignerUnsupported(user.pubkey);
return;
}
toast({
title: 'Bitcoin zap failed',
description: err.message,
variant: 'destructive',
});
},
onSettled: () => {
setIsZapping(false);
setProgress('idle');
},
});
return {
zap: mutation.mutate,
zapAsync: mutation.mutateAsync,
isZapping,
progress,
canZap: !!user && user.pubkey !== target.pubkey && canSignPsbt,
/** Whether the logged-in user has a PSBT-capable signer. */
canSignPsbt,
};
}
+181
View File
@@ -0,0 +1,181 @@
import { useQueries, useQuery } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import type { NostrEvent } from '@nostrify/nostrify';
import { fetchTxDetail, nostrPubkeyToBitcoinAddress } from '@/lib/bitcoin';
/** A single verified on-chain zap, with the amount that actually paid the recipient on-chain. */
export interface OnchainZapEntry {
/** The kind 8333 event. */
event: NostrEvent;
/** Bitcoin transaction id (lowercase hex). */
txid: string;
/** Pubkey of the sender (the 8333 event author). */
senderPubkey: string;
/** Pubkey of the recipient (from `p` tag). */
recipientPubkey: string;
/** Verified amount in sats — sum of tx outputs that pay the recipient's derived Taproot address. */
amountSats: number;
/** Sender's self-reported amount (may differ from verified). */
claimedAmountSats: number;
/** Comment from the 8333 event content. */
comment: string;
/** Unix timestamp of the 8333 event. */
createdAt: number;
/** Whether the Bitcoin tx is confirmed on-chain. */
confirmed: boolean;
}
/** Parse the txid from a kind 8333 event's `i` tag. Returns null if missing or malformed. */
export function extractOnchainZapTxid(event: NostrEvent): string | null {
const iTag = event.tags.find(([n, v]) => n === 'i' && typeof v === 'string' && v.startsWith('bitcoin:tx:'));
if (!iTag?.[1]) return null;
const txid = iTag[1].slice('bitcoin:tx:'.length).toLowerCase();
if (!/^[0-9a-f]{64}$/.test(txid)) return null;
return txid;
}
/** Parse the claimed amount (sats) from a kind 8333 event. */
export function extractOnchainZapClaimedAmount(event: NostrEvent): number {
const tag = event.tags.find(([n]) => n === 'amount');
if (!tag?.[1]) return 0;
const n = parseInt(tag[1], 10);
return Number.isFinite(n) && n > 0 ? n : 0;
}
/** Parse the recipient pubkey from a kind 8333 event (first `p` tag). */
export function extractOnchainZapRecipient(event: NostrEvent): string {
const tag = event.tags.find(([n]) => n === 'p');
return tag?.[1] ?? '';
}
/**
* Verify a kind 8333 on-chain zap event against the Bitcoin blockchain.
*
* Returns the verified amount (sum of tx outputs paying the recipient's
* derived Taproot address) and confirmation status. Returns `null` if the
* event is malformed or the transaction cannot be verified.
*
* A verified amount of 0 means the transaction exists but does not pay
* the claimed recipient — callers should discard such events.
*/
export async function verifyOnchainZap(event: NostrEvent): Promise<OnchainZapEntry | null> {
const txid = extractOnchainZapTxid(event);
const recipientPubkey = extractOnchainZapRecipient(event);
if (!txid || !recipientPubkey) return null;
// Reject self-zaps (sender == recipient). The sender already controls the
// destination address, so self-zaps are trivial to fabricate and contribute
// nothing meaningful to zap totals.
if (event.pubkey === recipientPubkey) return null;
const recipientAddress = nostrPubkeyToBitcoinAddress(recipientPubkey);
if (!recipientAddress) return null;
let detail;
try {
detail = await fetchTxDetail(txid);
} catch {
return null;
}
const amountSats = detail.outputs
.filter((o) => o.address === recipientAddress)
.reduce((sum, o) => sum + o.value, 0);
if (amountSats === 0) return null;
const claimed = extractOnchainZapClaimedAmount(event);
// If the sender is claiming more than the tx actually paid, cap it at the verified amount.
const effectiveClaim = Math.min(claimed || amountSats, amountSats);
return {
event,
txid,
senderPubkey: event.pubkey,
recipientPubkey,
amountSats: effectiveClaim,
claimedAmountSats: claimed,
comment: event.content,
createdAt: event.created_at,
confirmed: detail.confirmed,
};
}
/**
* Query all kind 8333 on-chain zaps targeting a specific event, then verify
* each one on-chain. Returns only verified entries (deduped by txid).
*/
export function useOnchainZaps(target: NostrEvent | undefined) {
const { nostr } = useNostr();
const isAddressable = target && target.kind >= 30000 && target.kind < 40000;
const dTag = isAddressable
? target.tags.find(([n]) => n === 'd')?.[1] ?? ''
: '';
const aCoord = isAddressable && target ? `${target.kind}:${target.pubkey}:${dTag}` : '';
// Step 1: fetch the raw kind 8333 events for this target
const eventsQuery = useQuery({
queryKey: ['onchain-zaps', 'events', target?.id ?? '', aCoord],
queryFn: async ({ signal }) => {
if (!target) return [] as NostrEvent[];
const timeout = AbortSignal.timeout(5000);
const combined = AbortSignal.any([signal, timeout]);
const filters: Parameters<typeof nostr.query>[0] = [
{ kinds: [8333], '#e': [target.id], limit: 100 },
];
if (aCoord) {
filters.push({ kinds: [8333], '#a': [aCoord], limit: 100 });
}
const events = await nostr.query(filters, { signal: combined });
// Dedupe by event id, then by txid (one canonical zap per tx per target).
const byId = new Map<string, NostrEvent>();
for (const e of events) byId.set(e.id, e);
const byTxid = new Map<string, NostrEvent>();
for (const e of byId.values()) {
const txid = extractOnchainZapTxid(e);
if (!txid) continue;
const existing = byTxid.get(txid);
// Prefer the earliest event for each txid (first to claim this tx).
if (!existing || e.created_at < existing.created_at) {
byTxid.set(txid, e);
}
}
return Array.from(byTxid.values());
},
enabled: !!target,
staleTime: 30_000,
});
// Step 2: verify each event on-chain (parallel, cached per txid)
const events = eventsQuery.data ?? [];
const verifications = useQueries({
queries: events.map((event) => ({
queryKey: ['onchain-zaps', 'verify', extractOnchainZapTxid(event), extractOnchainZapRecipient(event)],
queryFn: () => verifyOnchainZap(event),
staleTime: 60_000,
})),
});
const verified: OnchainZapEntry[] = verifications
.map((v) => v.data)
.filter((v): v is OnchainZapEntry => !!v);
// Sort by verified amount (largest first)
verified.sort((a, b) => b.amountSats - a.amountSats);
const totalSats = verified.reduce((s, v) => s + v.amountSats, 0);
const isLoading = eventsQuery.isLoading || verifications.some((v) => v.isLoading);
return {
zaps: verified,
totalSats,
count: verified.length,
isLoading,
};
}
+143
View File
@@ -0,0 +1,143 @@
import type { NostrSigner } from '@nostrify/types';
import { NSecSigner, NBrowserSigner, NConnectSigner } from '@nostrify/nostrify';
import type { NConnectSignerOpts } from '@nostrify/nostrify';
import { signPsbtLocal } from '@/lib/bitcoin';
// ---------------------------------------------------------------------------
// BtcSigner interface
// ---------------------------------------------------------------------------
/**
* A Nostr signer extended with Bitcoin PSBT signing capability.
*
* Implementations receive a hex-encoded unsigned PSBT, sign all Taproot
* inputs whose `tapInternalKey` matches the signer's key, and return the
* hex-encoded signed (but not finalized) PSBT.
*/
export interface BtcSigner extends NostrSigner {
signPsbt(psbtHex: string): Promise<string>;
}
/** Runtime check for whether a signer supports `signPsbt`. */
export function hasBtcSigning(signer: NostrSigner): signer is BtcSigner {
return typeof (signer as BtcSigner).signPsbt === 'function';
}
// ---------------------------------------------------------------------------
// NSecSignerBtc — local nsec signing
// ---------------------------------------------------------------------------
/**
* Extends `NSecSigner` with local Taproot PSBT signing.
*
* `NSecSigner` stores the secret key in a JS `#private` field that subclasses
* cannot access. To work around this, the constructor accepts the raw secret
* key bytes, passes them to `super()`, and keeps its own copy in a true
* runtime-private `#secretKeyBytes` field so the key is not reachable via
* property enumeration or reflection on the instance.
*/
export class NSecSignerBtc extends NSecSigner implements BtcSigner {
readonly #secretKeyBytes: Uint8Array;
constructor(secretKey: Uint8Array) {
super(secretKey);
this.#secretKeyBytes = new Uint8Array(secretKey);
}
async signPsbt(psbtHex: string): Promise<string> {
const privateKeyHex = Buffer.from(this.#secretKeyBytes).toString('hex');
return signPsbtLocal(psbtHex, privateKeyHex);
}
}
// ---------------------------------------------------------------------------
// NBrowserSignerBtc — NIP-07 extension signing
// ---------------------------------------------------------------------------
/**
* Extends `NBrowserSigner` with NIP-07 `window.nostr.signPsbt()` support.
*
* Calls the extension's `signPsbt` method if available. If the extension does
* not expose `signPsbt`, an error is thrown with a user-friendly message.
*/
export class NBrowserSignerBtc extends NBrowserSigner implements BtcSigner {
constructor(opts?: { timeout?: number }) {
super(opts);
}
async signPsbt(psbtHex: string): Promise<string> {
// `awaitNostr` is TypeScript-private but JavaScript-public at runtime.
const nostr = await (this as unknown as { awaitNostr(): Promise<Record<string, unknown>> }).awaitNostr();
if (typeof nostr.signPsbt !== 'function') {
throw new Error(
"Your browser extension doesn't support sending Bitcoin. Try a different extension, or log in with your secret key.",
);
}
const signPsbt = nostr.signPsbt as (hex: string) => Promise<string>;
return signPsbt(psbtHex);
}
}
// ---------------------------------------------------------------------------
// NConnectSignerBtc — NIP-46 remote signer
// ---------------------------------------------------------------------------
/**
* Heuristics for detecting whether a NIP-46 `sign_psbt` error reflects a
* missing-capability rejection (e.g. "method not supported", "unknown
* command") versus a transient operational failure (network, user rejection,
* malformed input). We have to match on strings because NIP-46 errors are
* plain strings without structured codes.
*/
const CAPABILITY_ERROR_PATTERNS = [
/unknown\s+(method|command)/i,
/not\s+(implemented|supported|found)/i,
/unsupported\s+method/i,
/method\s+not\s+found/i,
/invalid\s+method/i,
/no\s+such\s+method/i,
];
function looksLikeCapabilityError(msg: string): boolean {
return CAPABILITY_ERROR_PATTERNS.some((re) => re.test(msg));
}
/**
* Extends `NConnectSigner` with NIP-46 `sign_psbt` RPC support.
*
* Sends a `sign_psbt` command over the NIP-46 relay channel. The remote
* signer handles the TapTweak and Schnorr signing internally.
*
* NIP-46 returns unstructured string errors, so we use pattern matching to
* distinguish capability failures (the signer doesn't know the method) from
* operational failures (network, user rejection, bad input). Only capability
* failures are re-wrapped with the "doesn't support sending Bitcoin" message
* that flips the UI into the unsupported state; everything else propagates
* unchanged so the caller can surface the real error.
*/
export class NConnectSignerBtc extends NConnectSigner implements BtcSigner {
constructor(opts: NConnectSignerOpts) {
super(opts);
}
async signPsbt(psbtHex: string): Promise<string> {
// `cmd` is TypeScript-private but JavaScript-public at runtime.
const cmd = (this as unknown as { cmd(method: string, params: string[]): Promise<string> }).cmd;
try {
return await cmd.call(this, 'sign_psbt', [psbtHex]);
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
if (looksLikeCapabilityError(msg)) {
throw new Error(
`Your remote signer doesn't support sending Bitcoin. Update your signer, or log in with your secret key. (${msg})`,
);
}
// Not a capability failure — propagate the original error so the user
// sees the actual reason (timeout, rejection, malformed PSBT, etc.).
throw error;
}
}
}
+179
View File
@@ -0,0 +1,179 @@
import { beforeAll, describe, expect, it } from 'vitest';
import { nip19 } from 'nostr-tools';
import * as bitcoin from 'bitcoinjs-lib';
import * as ecc from '@bitcoinerlab/secp256k1';
import '@/lib/polyfills';
import {
isLargeAmount,
LARGE_AMOUNT_USD_THRESHOLD,
nostrPubkeyToBitcoinAddress,
npubToBitcoinAddress,
validateBitcoinAddress,
} from '@/lib/bitcoin';
// Initialise ECC once for this test file. In the running app, `main.tsx`
// does this at startup; in a test process `main.tsx` is never imported.
beforeAll(() => {
bitcoin.initEccLib(ecc);
});
/**
* Regression test vectors for key-path-only P2TR address derivation using the
* Nostr pubkey directly as the internal key (no script tree).
*
* Each vector was produced by the live `bitcoinjs-lib` + `@bitcoinerlab/secp256k1`
* toolchain and independently validated against the address's bech32m
* checksum. They serve as regression fixtures: if the derivation ever changes
* (library upgrade, ECC backend switch, etc.) these tests will fail loudly.
*
* Note: these are NOT the addresses in the BIP-341 wallet test vectors,
* because those vectors use a non-empty script tree (merkle root); our
* implementation uses a key-path-only spend path (empty merkle root), which
* is the correct derivation for mapping a Nostr pubkey to a spendable address.
*/
describe('nostrPubkeyToBitcoinAddress', () => {
it('derives the expected key-path-only Taproot address (fixture 1)', () => {
const internalPubkey = 'd6889cb081036e0faefa3a35157ad71086b123b2b144b649798b494c300a961d';
const expected = 'bc1p2wsldez5mud2yam29q22wgfh9439spgduvct83k3pm50fcxa5dps59h4z5';
expect(nostrPubkeyToBitcoinAddress(internalPubkey)).toBe(expected);
});
it('derives the expected key-path-only Taproot address (fixture 2)', () => {
const internalPubkey = '187791b6f712a8ea41c8ecdd0ee77fab3e85263b37e1ec18a3651926b3a6cf27';
const expected = 'bc1pjxzw9tm6qatyapu3c409dg8k23p4hjlk4ehwwlsum3emjqsaetrqppyu2z';
expect(nostrPubkeyToBitcoinAddress(internalPubkey)).toBe(expected);
});
it('derives the expected key-path-only Taproot address (fixture 3)', () => {
const internalPubkey = '82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2';
const expected = 'bc1p2jdrzv2w45xws7qlguk0acmz9clje8fasvhx3kv3cgpmhm8qtzhsq6fyhy';
expect(nostrPubkeyToBitcoinAddress(internalPubkey)).toBe(expected);
});
it('produces a bech32m mainnet address that passes validation', () => {
const pubkey = '82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2';
const address = nostrPubkeyToBitcoinAddress(pubkey);
expect(address.startsWith('bc1p')).toBe(true);
expect(validateBitcoinAddress(address)).toBe(true);
});
it('is deterministic — same input yields the same non-empty address', () => {
// Use a pubkey known to be a valid on-curve secp256k1 x-only point.
const pubkey = 'd6889cb081036e0faefa3a35157ad71086b123b2b144b649798b494c300a961d';
const a1 = nostrPubkeyToBitcoinAddress(pubkey);
const a2 = nostrPubkeyToBitcoinAddress(pubkey);
expect(a1).toBe(a2);
expect(a1).not.toBe('');
});
it('returns empty string for malformed pubkeys instead of throwing', () => {
// Too short.
expect(nostrPubkeyToBitcoinAddress('abc')).toBe('');
// Non-hex characters.
expect(nostrPubkeyToBitcoinAddress('z'.repeat(64))).toBe('');
// Empty string.
expect(nostrPubkeyToBitcoinAddress('')).toBe('');
// Odd length (not a whole number of bytes).
expect(nostrPubkeyToBitcoinAddress('a'.repeat(63))).toBe('');
});
it('returns empty string for hex that is not a valid secp256k1 x-only point', () => {
// Suppress the catch-block console.error for this test so it doesn't
// pollute the test output. The function is expected to log and return ''.
const origError = console.error;
console.error = () => {};
try {
// Valid 64-char hex, but not a valid on-curve secp256k1 x-only point.
expect(nostrPubkeyToBitcoinAddress('e7a2e3b5f1c8d4a6b9c0e1f2d3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2')).toBe('');
} finally {
console.error = origError;
}
});
it('accepts both upper- and lower-case hex', () => {
const lower = 'd6889cb081036e0faefa3a35157ad71086b123b2b144b649798b494c300a961d';
const upper = lower.toUpperCase();
expect(nostrPubkeyToBitcoinAddress(lower)).toBe(nostrPubkeyToBitcoinAddress(upper));
});
});
describe('npubToBitcoinAddress', () => {
it('decodes an npub and derives the matching Taproot address', () => {
// Any valid Nostr pubkey works — we just verify round-trip consistency.
const pubkey = '82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2';
const npub = nip19.npubEncode(pubkey);
const fromHex = nostrPubkeyToBitcoinAddress(pubkey);
const fromNpub = npubToBitcoinAddress(npub);
expect(fromNpub).toBe(fromHex);
});
it('throws on non-npub NIP-19 input', () => {
const note = nip19.noteEncode('d6889cb081036e0faefa3a35157ad71086b123b2b144b649798b494c300a961d');
expect(() => npubToBitcoinAddress(note)).toThrow(/npub/i);
});
});
describe('validateBitcoinAddress', () => {
it('accepts valid bech32m P2TR addresses', () => {
expect(validateBitcoinAddress('bc1p2wsldez5mud2yam29q22wgfh9439spgduvct83k3pm50fcxa5dps59h4z5')).toBe(true);
});
it('accepts legacy P2PKH and P2SH addresses', () => {
expect(validateBitcoinAddress('1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2')).toBe(true);
expect(validateBitcoinAddress('3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy')).toBe(true);
});
it('rejects malformed addresses', () => {
expect(validateBitcoinAddress('')).toBe(false);
expect(validateBitcoinAddress('not-an-address')).toBe(false);
// Valid-looking bech32m with broken checksum (flipped last char).
expect(validateBitcoinAddress('bc1p2wsldez5mud2yam29q22wgfh9439spgduvct83k3pm50fcxa5dps59h4z6')).toBe(false);
});
});
describe('isLargeAmount', () => {
// Assume a BTC price of $100_000 for easy arithmetic. 1 BTC = $100k, so
// 1 sat = $0.001 and the $100 threshold corresponds to 100_000 sats.
const PRICE = 100_000;
it('returns true when the USD value is above the threshold', () => {
// 200,000 sats @ $100k/BTC = $200 — well above $100.
expect(isLargeAmount(200_000, PRICE)).toBe(true);
});
it('returns true at exactly the threshold', () => {
// 100,000 sats @ $100k/BTC = $100 — at the threshold (inclusive).
expect(isLargeAmount(100_000, PRICE)).toBe(true);
});
it('returns false below the threshold', () => {
// 50,000 sats @ $100k/BTC = $50 — below $100.
expect(isLargeAmount(50_000, PRICE)).toBe(false);
});
it('returns false when btcPrice is undefined', () => {
expect(isLargeAmount(10_000_000, undefined)).toBe(false);
});
it('returns false for non-positive sats or prices', () => {
expect(isLargeAmount(0, PRICE)).toBe(false);
expect(isLargeAmount(-1, PRICE)).toBe(false);
expect(isLargeAmount(100_000, 0)).toBe(false);
expect(isLargeAmount(100_000, -PRICE)).toBe(false);
expect(isLargeAmount(100_000, NaN)).toBe(false);
});
it('exports a sensible default threshold', () => {
expect(LARGE_AMOUNT_USD_THRESHOLD).toBe(100);
});
});
+680
View File
@@ -0,0 +1,680 @@
import * as bitcoin from 'bitcoinjs-lib';
import { toXOnly } from 'bitcoinjs-lib';
import { nip19 } from 'nostr-tools';
import * as ecc from '@bitcoinerlab/secp256k1';
import { ECPairFactory, type ECPairAPI } from 'ecpair';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** Base URL for the mempool.space Esplora-compatible REST API. */
const MEMPOOL_API = 'https://mempool.space/api';
/** Standard Bitcoin dust limit in satoshis. */
const DUST_LIMIT = 546;
/** Estimated vBytes per P2TR input. */
const VBYTES_PER_INPUT = 57.5;
/** Estimated vBytes per P2TR output. */
const VBYTES_PER_OUTPUT = 43;
/** Estimated vBytes for transaction overhead (version, locktime, etc.). */
const VBYTES_OVERHEAD = 10.5;
// ---------------------------------------------------------------------------
// ECC initialisation (lazy)
// ---------------------------------------------------------------------------
let _ECPair: ECPairAPI | null = null;
function getECPair(): ECPairAPI {
if (!_ECPair) {
bitcoin.initEccLib(ecc);
_ECPair = ECPairFactory(ecc);
}
return _ECPair;
}
/**
* Strict 32-byte hex validator. Rejects anything that isn't exactly 64
* lowercase-or-uppercase hex characters.
*/
function isValidPubkeyHex(hex: string): boolean {
return typeof hex === 'string' && /^[0-9a-fA-F]{64}$/.test(hex);
}
/**
* Convert a Nostr public key (32-byte hex) to a Bitcoin Taproot (P2TR) address.
*
* Both Nostr and Bitcoin Taproot use secp256k1 with 32-byte x-only public keys
* (Schnorr / BIP-340), so the key can be used directly as a Taproot internal
* public key with no mathematical conversion.
*
* Returns an empty string if the input is malformed or not a valid x-only key
* on the secp256k1 curve.
*/
export function nostrPubkeyToBitcoinAddress(pubkeyHex: string): string {
if (!isValidPubkeyHex(pubkeyHex)) return '';
try {
const pubkeyBuffer = Buffer.from(pubkeyHex, 'hex');
const { address } = bitcoin.payments.p2tr({
internalPubkey: pubkeyBuffer,
network: bitcoin.networks.bitcoin,
});
return address || '';
} catch (error) {
console.error('Error generating Bitcoin address:', error);
return '';
}
}
/**
* Convert a bech32 `npub1...` identifier to a Bitcoin Taproot (P2TR) address.
* Decodes the npub to a hex pubkey, then delegates to {@link nostrPubkeyToBitcoinAddress}.
*/
export function npubToBitcoinAddress(npub: string): string {
const decoded = nip19.decode(npub);
if (decoded.type !== 'npub') {
throw new Error('Invalid npub format');
}
return nostrPubkeyToBitcoinAddress(decoded.data);
}
// ---------------------------------------------------------------------------
// Balance / Address data (wallet page)
// ---------------------------------------------------------------------------
/** Balance data returned by the Esplora API. */
export interface AddressData {
/** Confirmed on-chain balance in satoshis. */
balance: number;
/** Unconfirmed mempool balance in satoshis. */
pendingBalance: number;
/** Sum of confirmed + pending balance. */
totalBalance: number;
/** Total satoshis ever received (confirmed). */
totalReceived: number;
/** Total satoshis ever sent (confirmed). */
totalSent: number;
/** Confirmed transaction count. */
txCount: number;
/** Pending (mempool) transaction count. */
pendingTxCount: number;
}
/**
* Fetch balance and transaction stats for a Bitcoin address from the
* mempool.space Esplora API.
*/
export async function fetchAddressData(address: string): Promise<AddressData> {
const response = await fetch(`${MEMPOOL_API}/address/${address}`);
if (!response.ok) {
throw new Error('Failed to fetch balance');
}
const data = await response.json();
const confirmedBalance = data.chain_stats.funded_txo_sum - data.chain_stats.spent_txo_sum;
const pendingBalance = data.mempool_stats.funded_txo_sum - data.mempool_stats.spent_txo_sum;
return {
balance: confirmedBalance,
pendingBalance,
totalBalance: confirmedBalance + pendingBalance,
totalReceived: data.chain_stats.funded_txo_sum,
totalSent: data.chain_stats.spent_txo_sum,
txCount: data.chain_stats.tx_count,
pendingTxCount: data.mempool_stats.tx_count,
};
}
// ---------------------------------------------------------------------------
// Formatting helpers
// ---------------------------------------------------------------------------
/** Convert satoshis to a BTC string with up to 8 decimal places. */
export function satsToBTC(sats: number): string {
return (sats / 100_000_000).toFixed(8);
}
/**
* Convert satoshis to a BTC string with trailing zeros stripped.
* E.g. `formatBTC(100_000_000)` → `"1"`, `formatBTC(1_234_560)` → `"0.0123456"`.
*/
export function formatBTC(sats: number): string {
return satsToBTC(sats).replace(/\.?0+$/, '');
}
/** Format a satoshi amount with locale-aware thousand separators. */
export function formatSats(sats: number): string {
return sats.toLocaleString();
}
/** Fetch the current BTC price in USD from the CoinGecko API. */
export async function fetchBtcPrice(): Promise<number> {
const response = await fetch(
'https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd',
);
if (!response.ok) {
throw new Error('Failed to fetch BTC price');
}
const data = await response.json();
return data.bitcoin.usd;
}
/** Convert a BTC amount to satoshis (rounded to nearest integer). */
export function btcToSats(btc: number): number {
return Math.round(btc * 100_000_000);
}
/**
* USD threshold above which Bitcoin send/zap flows require explicit
* confirmation (two-tap). Chosen to catch meaningful dollar amounts without
* nagging on everyday $5$25 zaps.
*/
export const LARGE_AMOUNT_USD_THRESHOLD = 100;
/**
* Whether a given satoshi amount crosses the "large amount" threshold at the
* current BTC/USD price. Returns false when `btcPrice` is unavailable, so the
* UI does not arm confirmation without a known USD value.
*/
export function isLargeAmount(sats: number, btcPrice: number | undefined): boolean {
if (!btcPrice || !Number.isFinite(btcPrice) || btcPrice <= 0) return false;
if (!Number.isFinite(sats) || sats <= 0) return false;
const usd = (sats / 100_000_000) * btcPrice;
return usd >= LARGE_AMOUNT_USD_THRESHOLD;
}
/** Convert satoshis to USD given a BTC price. */
export function satsToUSD(sats: number, btcPrice: number): string {
const btc = sats / 100_000_000;
return (btc * btcPrice).toLocaleString('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
}
// ---------------------------------------------------------------------------
// Wallet-page transaction list (simplified per-address view)
// ---------------------------------------------------------------------------
/** A simplified transaction relevant to a specific address. */
export interface Transaction {
/** Transaction ID (hex). */
txid: string;
/** Net satoshi change for the address (positive = received, negative = sent). */
amount: number;
/** Whether this is a receive or send relative to the address. */
type: 'receive' | 'send';
/** Whether the transaction is confirmed. */
confirmed: boolean;
/** Unix timestamp of the block (undefined if unconfirmed). */
timestamp?: number;
}
/**
* Fetch transactions for a Bitcoin address from the mempool.space Esplora API.
* Returns simplified transactions with net amount relative to the address.
*/
export async function fetchTransactions(address: string): Promise<Transaction[]> {
const response = await fetch(`${MEMPOOL_API}/address/${address}/txs`);
if (!response.ok) {
throw new Error('Failed to fetch transactions');
}
const txs = await response.json();
return txs.map((tx: Record<string, unknown>) => {
const vin = tx.vin as Array<{ prevout: { scriptpubkey_address?: string; value: number } | null }>;
const vout = tx.vout as Array<{ scriptpubkey_address?: string; value: number }>;
const status = tx.status as { confirmed: boolean; block_time?: number };
// Sum sats flowing out of this address (inputs we owned)
const totalIn = vin.reduce((sum, input) => {
if (input.prevout?.scriptpubkey_address === address) {
return sum + input.prevout.value;
}
return sum;
}, 0);
// Sum sats flowing into this address (outputs we own)
const totalOut = vout.reduce((sum, output) => {
if (output.scriptpubkey_address === address) {
return sum + output.value;
}
return sum;
}, 0);
const net = totalOut - totalIn;
return {
txid: tx.txid as string,
amount: Math.abs(net),
type: net >= 0 ? 'receive' : 'send',
confirmed: status.confirmed,
timestamp: status.block_time,
} satisfies Transaction;
});
}
// ---------------------------------------------------------------------------
// Full transaction detail (NIP-73 /i/bitcoin:tx:... page)
// ---------------------------------------------------------------------------
/** A single input in a full transaction. */
export interface TxInput {
txid: string;
vout: number;
address?: string;
value: number;
isCoinbase: boolean;
}
/** A single output in a full transaction. */
export interface TxOutput {
address?: string;
value: number;
scriptpubkeyType: string;
/** True if the output has been spent. */
spent: boolean;
}
/** Full transaction detail returned by the Esplora API. */
export interface TxDetail {
txid: string;
version: number;
locktime: number;
size: number;
weight: number;
fee: number;
confirmed: boolean;
blockHeight?: number;
blockHash?: string;
blockTime?: number;
inputs: TxInput[];
outputs: TxOutput[];
/** Total value of all inputs (sats). */
totalInput: number;
/** Total value of all outputs (sats). */
totalOutput: number;
}
/** Fetch full transaction details from mempool.space. */
export async function fetchTxDetail(txid: string): Promise<TxDetail> {
const response = await fetch(`${MEMPOOL_API}/tx/${txid}`);
if (!response.ok) throw new Error('Failed to fetch transaction');
const tx = await response.json();
const vin = tx.vin as Array<{
txid: string;
vout: number;
prevout: { scriptpubkey_address?: string; value: number } | null;
is_coinbase: boolean;
}>;
const vout = tx.vout as Array<{
scriptpubkey_address?: string;
value: number;
scriptpubkey_type: string;
}>;
const status = tx.status as { confirmed: boolean; block_height?: number; block_hash?: string; block_time?: number };
const inputs: TxInput[] = vin.map((input) => ({
txid: input.txid,
vout: input.vout,
address: input.prevout?.scriptpubkey_address,
value: input.prevout?.value ?? 0,
isCoinbase: input.is_coinbase,
}));
const outputs: TxOutput[] = vout.map((output) => ({
address: output.scriptpubkey_address,
value: output.value,
scriptpubkeyType: output.scriptpubkey_type,
spent: false, // Esplora /tx endpoint doesn't include spending info
}));
const totalInput = inputs.reduce((sum, i) => sum + i.value, 0);
const totalOutput = outputs.reduce((sum, o) => sum + o.value, 0);
return {
txid: tx.txid as string,
version: tx.version as number,
locktime: tx.locktime as number,
size: tx.size as number,
weight: tx.weight as number,
fee: tx.fee as number,
confirmed: status.confirmed,
blockHeight: status.block_height,
blockHash: status.block_hash,
blockTime: status.block_time,
inputs,
outputs,
totalInput,
totalOutput,
};
}
// ---------------------------------------------------------------------------
// Full address detail (NIP-73 /i/bitcoin:address:... page)
// ---------------------------------------------------------------------------
/** Full address detail combining balance stats + recent transactions. */
export interface AddressDetail {
address: string;
balance: number;
pendingBalance: number;
totalBalance: number;
totalReceived: number;
totalSent: number;
txCount: number;
pendingTxCount: number;
/** Most recent transactions (up to 25). */
recentTxs: Transaction[];
}
/** Fetch full address details (balance + recent txs) from mempool.space. */
export async function fetchAddressDetail(address: string): Promise<AddressDetail> {
const [addrData, txs] = await Promise.all([
fetchAddressData(address),
fetchTransactions(address),
]);
return {
address,
...addrData,
recentTxs: txs.slice(0, 25),
};
}
// ---------------------------------------------------------------------------
// Sending: UTXOs, fee estimation, transaction construction, broadcast
// ---------------------------------------------------------------------------
/** An unspent transaction output. */
export interface UTXO {
txid: string;
vout: number;
/** Value in satoshis. */
value: number;
status: {
confirmed: boolean;
block_height?: number;
block_hash?: string;
block_time?: number;
};
}
/** Fetch UTXOs for a Bitcoin address from mempool.space. */
export async function fetchUTXOs(address: string): Promise<UTXO[]> {
const response = await fetch(`${MEMPOOL_API}/address/${address}/utxo`);
if (!response.ok) throw new Error('Failed to fetch UTXOs');
return response.json();
}
/** Fee rate estimates keyed by confirmation speed. */
export interface FeeRates {
/** ~10 min / next block (target 1). */
fastestFee: number;
/** ~30 min (target 3). */
halfHourFee: number;
/** ~1 hour (target 6). */
hourFee: number;
/** ~1 day (target 144). */
economyFee: number;
/** Minimum relay fee (target 504). */
minimumFee: number;
}
/** Fetch recommended fee rates (sat/vB) from mempool.space. */
export async function getFeeRates(): Promise<FeeRates> {
const response = await fetch(`${MEMPOOL_API}/fee-estimates`);
if (!response.ok) throw new Error('Failed to fetch fee estimates');
const data = await response.json();
return {
fastestFee: Math.ceil(data['1'] || 1),
halfHourFee: Math.ceil(data['3'] || 1),
hourFee: Math.ceil(data['6'] || 1),
economyFee: Math.ceil(data['144'] || 1),
minimumFee: Math.ceil(data['504'] || 1),
};
}
/**
* Estimate the fee for a P2TR transaction in satoshis.
*
* @param numInputs Number of Taproot inputs.
* @param numOutputs Number of outputs (recipient + optional change).
* @param feeRate Fee rate in sat/vB.
*/
export function estimateFee(numInputs: number, numOutputs: number, feeRate: number): number {
const vBytes = numInputs * VBYTES_PER_INPUT + numOutputs * VBYTES_PER_OUTPUT + VBYTES_OVERHEAD;
return Math.ceil(vBytes * feeRate);
}
/**
* Validate a Bitcoin address (mainnet). Returns `true` if the address has a
* valid format and checksum, `false` otherwise.
*/
export function validateBitcoinAddress(address: string): boolean {
try {
bitcoin.address.toOutputScript(address, bitcoin.networks.bitcoin);
return true;
} catch {
return false;
}
}
/** Broadcast a signed transaction hex to the Bitcoin network via mempool.space. Returns the txid. */
export async function broadcastTransaction(txHex: string): Promise<string> {
const response = await fetch(`${MEMPOOL_API}/tx`, {
method: 'POST',
body: txHex,
});
if (!response.ok) {
const body = await response.text();
throw new Error(`Broadcast failed: ${body}`);
}
return response.text();
}
/**
* Compute the maximum sendable amount (in sats) after fees.
*
* @param totalBalance Total spendable sats across all UTXOs.
* @param numInputs Number of UTXOs that will be consumed.
* @param feeRate Fee rate in sat/vB.
* @returns The max amount in sats, or 0 if the balance cannot cover fees.
*/
export function maxSendable(totalBalance: number, numInputs: number, feeRate: number): number {
// When sending max there is no change output, so only 1 output.
const fee = estimateFee(numInputs, 1, feeRate);
return Math.max(0, totalBalance - fee);
}
/** Result of building an unsigned PSBT. */
export interface UnsignedPsbt {
/** Hex-encoded unsigned PSBT. */
psbtHex: string;
/** Fee in satoshis. */
fee: number;
}
/**
* Build an unsigned Taproot PSBT ready for signing.
*
* This function constructs the PSBT with all inputs and outputs but does NOT
* sign it. The returned hex can be passed to any signer (local nsec, NIP-07
* extension, or NIP-46 remote signer).
*
* @param senderPubkeyHex 32-byte hex x-only public key of the sender.
* @param toAddress Recipient Bitcoin address.
* @param amountSats Amount to send in satoshis.
* @param utxos Available UTXOs (all will be consumed).
* @param feeRate Fee rate in sat/vB.
*/
export function buildUnsignedPsbt(
senderPubkeyHex: string,
toAddress: string,
amountSats: number,
utxos: UTXO[],
feeRate: number,
): UnsignedPsbt {
const internalPubkey = Buffer.from(senderPubkeyHex, 'hex');
// Derive change address (same Taproot address as sender)
const { address: changeAddress } = bitcoin.payments.p2tr({
internalPubkey,
network: bitcoin.networks.bitcoin,
});
if (!changeAddress) throw new Error('Failed to derive change address');
// Build PSBT, add all UTXOs as inputs
const psbt = new bitcoin.Psbt({ network: bitcoin.networks.bitcoin });
let totalInput = 0;
for (const utxo of utxos) {
psbt.addInput({
hash: utxo.txid,
index: utxo.vout,
witnessUtxo: {
script: bitcoin.payments.p2tr({
internalPubkey,
network: bitcoin.networks.bitcoin,
}).output!,
value: BigInt(utxo.value),
},
tapInternalKey: internalPubkey,
});
totalInput += utxo.value;
}
// Estimate fee — first assume 2 outputs (recipient + change). Change at the
// dust limit exactly is still standard, so use >= (not >) per BIP-141/P2TR
// relay policy (minimum non-dust output is 546 sats).
const change2Out = totalInput - amountSats - estimateFee(utxos.length, 2, feeRate);
const hasChange = change2Out >= DUST_LIMIT;
const numOutputs = hasChange ? 2 : 1;
const fee = estimateFee(utxos.length, numOutputs, feeRate);
const change = totalInput - amountSats - fee;
if (change < 0) {
throw new Error(
`Insufficient funds. Need ${(amountSats + fee).toLocaleString()} sats, have ${totalInput.toLocaleString()} sats.`,
);
}
// Add outputs
psbt.addOutput({ address: toAddress, value: BigInt(amountSats) });
if (hasChange) {
psbt.addOutput({ address: changeAddress, value: BigInt(change) });
}
return { psbtHex: psbt.toHex(), fee };
}
/**
* Sign a PSBT locally using a raw private key (nsec).
*
* Applies the BIP-341 TapTweak to the private key, signs all inputs whose
* `tapInternalKey` matches, and returns the signed (but not finalized) PSBT hex.
*
* @param psbtHex Hex-encoded unsigned PSBT.
* @param privateKeyHex 32-byte hex private key.
* @returns Hex-encoded signed PSBT (not finalized).
*/
export function signPsbtLocal(psbtHex: string, privateKeyHex: string): string {
bitcoin.initEccLib(ecc);
const psbt = bitcoin.Psbt.fromHex(psbtHex, { network: bitcoin.networks.bitcoin });
const keyPair = getECPair().fromPrivateKey(Buffer.from(privateKeyHex, 'hex'));
const internalPubkey = toXOnly(keyPair.publicKey);
// Tweak private key for Taproot key-path spending (BIP-341)
const tweakedSigner = keyPair.tweak(
bitcoin.crypto.taggedHash('TapTweak', internalPubkey),
);
// Per the NIP spec: inputs whose `tapInternalKey` does not match the
// signer's x-only pubkey MUST be left unchanged. This matters for future
// multi-signer PSBTs; today `buildUnsignedPsbt` only ever adds the user's
// own UTXOs, so in practice every input matches.
let signedAny = false;
for (let i = 0; i < psbt.inputCount; i++) {
const input = psbt.data.inputs[i];
const inputInternalKey = input.tapInternalKey;
if (!inputInternalKey || !Buffer.from(inputInternalKey).equals(Buffer.from(internalPubkey))) {
continue;
}
psbt.signInput(i, tweakedSigner);
signedAny = true;
}
if (!signedAny) {
throw new Error('No inputs in this PSBT are owned by the signer.');
}
return psbt.toHex();
}
/**
* Finalize a signed PSBT and extract the raw transaction hex.
*
* @param psbtHex Hex-encoded signed PSBT.
* @returns Raw transaction hex ready for broadcast.
*/
export function finalizePsbt(psbtHex: string): string {
bitcoin.initEccLib(ecc);
const psbt = bitcoin.Psbt.fromHex(psbtHex, { network: bitcoin.networks.bitcoin });
psbt.finalizeAllInputs();
return psbt.extractTransaction().toHex();
}
/**
* Create, sign, and return a raw Bitcoin Taproot transaction.
*
* Convenience wrapper that calls {@link buildUnsignedPsbt},
* {@link signPsbtLocal}, and {@link finalizePsbt} in sequence.
*
* @param privateKeyHex 32-byte hex private key (from Nostr nsec).
* @param toAddress Recipient Bitcoin address.
* @param amountSats Amount to send in satoshis.
* @param utxos Available UTXOs (all will be consumed).
* @param feeRate Fee rate in sat/vB.
* @returns The signed transaction hex and the fee paid.
*/
export function createBitcoinTransaction(
privateKeyHex: string,
toAddress: string,
amountSats: number,
utxos: UTXO[],
feeRate: number,
): { txHex: string; fee: number } {
// Derive the x-only pubkey from the private key for buildUnsignedPsbt
const keyPair = getECPair().fromPrivateKey(Buffer.from(privateKeyHex, 'hex'));
const internalPubkey = toXOnly(keyPair.publicKey);
const senderPubkeyHex = Buffer.from(internalPubkey).toString('hex');
const { psbtHex, fee } = buildUnsignedPsbt(senderPubkeyHex, toAddress, amountSats, utxos, feeRate);
const signedHex = signPsbtLocal(psbtHex, privateKeyHex);
const txHex = finalizePsbt(signedHex);
return { txHex, fee };
}
+24
View File
@@ -10,6 +10,8 @@ export type ExternalContent =
| { type: 'url'; value: string }
| { type: 'isbn'; value: string }
| { type: 'iso3166'; value: string; code: string }
| { type: 'bitcoin-tx'; value: string; txid: string }
| { type: 'bitcoin-address'; value: string; address: string }
| { type: 'unknown'; value: string };
/** Parse a URI string into a typed external content object. */
@@ -21,6 +23,16 @@ export function parseExternalUri(uri: string): ExternalContent {
const code = uri.slice('iso3166:'.length);
return { type: 'iso3166', value: uri, code };
}
// NIP-73 Bitcoin transaction: bitcoin:tx:<txid>
const btcTxMatch = uri.match(/^bitcoin:tx:([0-9a-f]{64})$/i);
if (btcTxMatch) {
return { type: 'bitcoin-tx', value: uri, txid: btcTxMatch[1].toLowerCase() };
}
// NIP-73 Bitcoin address: bitcoin:address:<address>
const btcAddrMatch = uri.match(/^bitcoin:address:(.+)$/);
if (btcAddrMatch) {
return { type: 'bitcoin-address', value: uri, address: btcAddrMatch[1] };
}
if (uri.startsWith('http://') || uri.startsWith('https://')) {
return { type: 'url', value: uri };
}
@@ -89,6 +101,10 @@ export function headerLabel(content: ExternalContent): string {
const info = getCountryInfo(content.code);
return info?.subdivisionName ?? info?.name ?? 'Country';
}
case 'bitcoin-tx':
return 'Bitcoin Transaction';
case 'bitcoin-address':
return 'Bitcoin Address';
default:
return 'External Content';
}
@@ -112,6 +128,14 @@ export function seoTitle(content: ExternalContent, appName: string): string {
const seoName = seoInfo?.subdivisionName ?? seoInfo?.name;
return seoName ? `${seoName} | ${appName}` : `Country | ${appName}`;
}
case 'bitcoin-tx': {
const shortTxid = `${content.txid.slice(0, 8)}...${content.txid.slice(-8)}`;
return `Bitcoin TX ${shortTxid} | ${appName}`;
}
case 'bitcoin-address': {
const shortAddr = `${content.address.slice(0, 8)}...${content.address.slice(-6)}`;
return `Bitcoin Address ${shortAddr} | ${appName}`;
}
default:
return `External Content | ${appName}`;
}
+9
View File
@@ -1,3 +1,12 @@
/**
* Polyfill for Buffer in browser environment.
*
* Many Node.js libraries like bitcoinjs-lib expect Buffer to be globally available.
* This polyfill makes the buffer package's Buffer available globally.
*/
import { Buffer } from 'buffer';
globalThis.Buffer = Buffer;
/**
* Polyfill for AbortSignal.any()
*
+8
View File
@@ -32,6 +32,7 @@ import {
Settings,
Smile,
SmilePlus,
Wallet,
Sparkles,
Stars,
TrendingUp,
@@ -136,6 +137,13 @@ export const SIDEBAR_ITEMS: SidebarItemDef[] = [
requiresAuth: true,
},
{ id: "settings", label: "Settings", path: "/settings", icon: Settings },
{
id: "wallet",
label: "Wallet",
path: "/wallet",
icon: Wallet,
requiresAuth: true,
},
{ id: "changelog", label: "Changelog", path: "/changelog", icon: ScrollText },
{
id: "letters",
+10
View File
@@ -2,6 +2,7 @@ import type { NostrEvent, NostrSigner } from '@nostrify/types';
import { createElement } from 'react';
import { toast } from '@/hooks/useToast';
import { NudgeToastContent } from '@/components/SignerToastContent';
import { type BtcSigner, hasBtcSigning } from '@/lib/bitcoin-signers';
// ---------------------------------------------------------------------------
// Constants
@@ -35,6 +36,7 @@ const KIND_LABELS: Record<number, string> = {
7: 'reaction',
11: 'post',
16: 'repost',
8333: 'Bitcoin zap',
1111: 'comment',
1984: 'report',
4932: 'webxdc sync',
@@ -303,5 +305,13 @@ export function signerWithNudge(
wrapped.nip44 = wrapCrypto(signer.nip44);
}
// Forward signPsbt if the underlying signer supports Bitcoin signing.
if (hasBtcSigning(signer)) {
const btcWrapped = wrapped as BtcSigner;
const btcSigner = signer;
btcWrapped.signPsbt = (psbtHex: string) =>
run(() => btcSigner.signPsbt(psbtHex), undefined, 'sign');
}
return wrapped;
}
+6 -1
View File
@@ -1,8 +1,13 @@
import { createRoot } from 'react-dom/client';
// Import polyfills first
// Import polyfills first (Buffer must be globally available before bitcoinjs-lib)
import './lib/polyfills.ts';
// Initialize ECC library for bitcoinjs-lib (Taproot / Schnorr support)
import * as bitcoin from 'bitcoinjs-lib';
import * as ecc from '@bitcoinerlab/secp256k1';
bitcoin.initEccLib(ecc);
// Kick off cache hydration early so data is ready before components render.
import { hydrateNip05Cache } from '@/lib/nip05Cache';
import { hydrateProfileCache } from '@/lib/profileCache';
+3
View File
@@ -22,6 +22,7 @@ import {
BookContentHeader,
CountryContentHeader,
} from '@/components/ExternalContentHeader';
import { BitcoinTxHeader, BitcoinAddressHeader } from '@/components/BitcoinContentHeader';
import { PrecipitationEffect } from '@/components/PrecipitationEffect';
import { parseExternalUri, headerLabel, seoTitle, type ExternalContent } from '@/lib/externalContent';
import { ratingToStars } from '@/lib/bookstr';
@@ -287,6 +288,8 @@ export function ExternalContentPage() {
{content.type === 'url' && <UrlContentHeader url={content.value} />}
{content.type === 'isbn' && <BookContentHeader isbn={content.value} />}
{content.type === 'iso3166' && <CountryContentHeader code={content.code} />}
{content.type === 'bitcoin-tx' && <BitcoinTxHeader txid={content.txid} />}
{content.type === 'bitcoin-address' && <BitcoinAddressHeader address={content.address} />}
{content.type === 'unknown' && (
<div className="rounded-2xl border border-border p-5 text-center">
<Globe className="size-8 mx-auto mb-2 text-muted-foreground/40" />
+1
View File
@@ -164,6 +164,7 @@ function shellTitleForKind(kind?: number): string {
if (kind === 7) return "Reaction";
if (kind === 1018) return "Poll Vote";
if (kind === 9735) return "Zap";
if (kind === 8333) return "Bitcoin Zap";
if (kind === 0) return "Profile";
if (kind === 31124) return "Blobbi";
if (kind === 2473) return "Bird Detection";
+4 -3
View File
@@ -46,7 +46,6 @@ import { FlatThreadedReplyList } from '@/components/ThreadedReplyList';
import { useNip05Resolve } from '@/hooks/useNip05Resolve';
import { genUserName } from '@/lib/genUserName';
import { canZap } from '@/lib/canZap';
import { openUrl } from '@/lib/downloadFile';
import { EmojifiedText } from '@/components/CustomEmoji';
import { BioContent } from '@/components/BioContent';
@@ -185,8 +184,10 @@ function ProfileMoreMenu({ pubkey, displayName, open, onOpenChange, isOwnProfile
const [giveBadgeOpen, setGiveBadgeOpen] = useState(false);
const [followQROpen, setFollowQROpen] = useState(false);
const zapTriggerRef = useRef<HTMLSpanElement>(null);
const author = useAuthor(pubkey);
const showZap = !isOwnProfile && authorEvent && canZap(author.data?.metadata);
// Show zap action for any non-self profile. Both on-chain and Lightning
// zaps are offered inside the dialog (Lightning only when the author has
// a lud06/lud16 configured).
const showZap = !isOwnProfile && !!authorEvent;
const close = () => onOpenChange(false);
const openAfterClose = (setter: (v: boolean) => void) => {
+1 -2
View File
@@ -45,7 +45,6 @@ import { useUserReaction } from "@/hooks/useUserReaction";
import { DITTO_RELAY } from "@/lib/appRelays";
import { getAvatarShape } from "@/lib/avatarShape";
import { getContentWarning } from "@/lib/contentWarning";
import { canZap } from "@/lib/canZap";
import { EXTRA_KINDS } from "@/lib/extraKinds";
import { getRepostKind } from "@/lib/feedUtils";
import { formatNumber } from "@/lib/formatNumber";
@@ -339,7 +338,7 @@ export function VineCard({
const displayName = getDisplayName(metadata, event.pubkey);
const profileUrl = useProfileUrl(event.pubkey, metadata);
const { data: stats } = useEventStats(event.id, event);
const canZapAuthor = user && canZap(metadata);
const canZapAuthor = !!user && user.pubkey !== event.pubkey;
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
+236
View File
@@ -0,0 +1,236 @@
import { useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import { useSeoMeta } from '@unhead/react';
import { Bitcoin, Copy, Check, RefreshCw, Wallet, ChevronDown, ArrowDownLeft, ArrowUpRight, Send } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { PageHeader } from '@/components/PageHeader';
import { LoginArea } from '@/components/auth/LoginArea';
import { QRCodeCanvas } from '@/components/ui/qrcode';
import { SendBitcoinDialog } from '@/components/SendBitcoinDialog';
import { useAppContext } from '@/hooks/useAppContext';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useBitcoinWallet } from '@/hooks/useBitcoinWallet';
import { satsToUSD, formatBTC } from '@/lib/bitcoin';
import type { Transaction } from '@/lib/bitcoin';
export function WalletPage() {
const { config } = useAppContext();
const { user } = useCurrentUser();
const { bitcoinAddress, addressData, btcPrice, transactions, isLoading, error, refetch } = useBitcoinWallet();
const [copiedAddress, setCopiedAddress] = useState(false);
const [txOpen, setTxOpen] = useState(false);
const [sendOpen, setSendOpen] = useState(false);
useSeoMeta({
title: `Wallet | ${config.appName}`,
description: 'Your Bitcoin Taproot wallet derived from your Nostr identity.',
});
const copyAddress = async () => {
if (!bitcoinAddress) return;
try {
await navigator.clipboard.writeText(bitcoinAddress);
setCopiedAddress(true);
setTimeout(() => setCopiedAddress(false), 2000);
} catch {
// clipboard API not available
}
};
const truncatedAddress = bitcoinAddress
? `${bitcoinAddress.slice(0, 12)}...${bitcoinAddress.slice(-8)}`
: '';
return (
<main>
<PageHeader title="Wallet" icon={<Wallet className="size-5" />} />
{!user ? (
<div className="py-20 px-8 flex flex-col items-center gap-6 text-center">
<div className="p-4 rounded-full bg-primary/10">
<Bitcoin className="size-8 text-primary" />
</div>
<div className="space-y-2 max-w-xs">
<h2 className="text-xl font-bold">Your Bitcoin Wallet</h2>
<p className="text-muted-foreground text-sm">
Log in to see your Bitcoin Taproot address derived from your Nostr identity.
</p>
</div>
<LoginArea className="max-w-60" />
</div>
) : (
<div className="flex flex-col items-center px-4 pt-8 pb-4 space-y-6 max-w-sm mx-auto">
{/* Balance */}
{isLoading ? (
<div className="flex flex-col items-center space-y-2">
<Skeleton className="h-10 w-40 rounded-lg" />
<Skeleton className="h-4 w-24 rounded" />
</div>
) : error ? (
<div className="text-center space-y-3">
<p className="text-sm text-destructive">Failed to load balance</p>
<Button variant="outline" size="sm" onClick={() => refetch()}>
<RefreshCw className="size-3.5 mr-1.5" />
Retry
</Button>
</div>
) : addressData ? (
<div className="flex flex-col items-center space-y-1">
<span className="text-4xl font-bold tracking-tight">
{btcPrice
? satsToUSD(addressData.totalBalance, btcPrice)
: '---'}
</span>
<span className="text-sm text-muted-foreground">
{formatBTC(addressData.totalBalance)} BTC
</span>
{addressData.pendingBalance !== 0 && (
<span className="flex items-center gap-1 text-xs text-orange-500 dark:text-orange-400 pt-1">
<RefreshCw className="size-3 animate-spin" />
{btcPrice
? `${satsToUSD(addressData.pendingBalance, btcPrice)} pending`
: 'pending'}
</span>
)}
</div>
) : null}
{/* Send button */}
{addressData && (
<Button
variant="outline"
size="sm"
onClick={() => setSendOpen(true)}
className="rounded-full"
>
<Send className="size-3.5 mr-1.5" />
Send
</Button>
)}
<SendBitcoinDialog
isOpen={sendOpen}
onClose={() => setSendOpen(false)}
btcPrice={btcPrice}
/>
{/* QR Code */}
<div className="rounded-2xl bg-white p-4 shadow-sm">
<QRCodeCanvas value={bitcoinAddress} size={200} level="M" />
</div>
{/* Address + copy */}
<button
onClick={copyAddress}
className="flex items-center gap-2 rounded-full border px-4 py-2 text-sm font-mono text-muted-foreground hover:bg-muted/50 transition-colors cursor-pointer"
>
{truncatedAddress}
{copiedAddress ? (
<Check className="size-3.5 text-green-500" />
) : (
<Copy className="size-3.5" />
)}
</button>
{/* Transactions */}
{transactions && transactions.length > 0 && (
<>
<button
onClick={() => setTxOpen((o) => !o)}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
>
Transactions
<ChevronDown className={`size-3 transition-transform duration-200 ${txOpen ? 'rotate-180' : ''}`} />
</button>
<TxAccordion open={txOpen}>
<div className="w-full divide-y">
{transactions.map((tx) => (
<TxRow key={tx.txid} tx={tx} btcPrice={btcPrice} />
))}
</div>
</TxAccordion>
</>
)}
</div>
)}
</main>
);
}
/** Accordion wrapper using grid-template-rows for smooth height animation. */
function TxAccordion({ open, children }: { open: boolean; children: React.ReactNode }) {
const contentRef = useRef<HTMLDivElement>(null);
return (
<div
className="w-full grid transition-[grid-template-rows] duration-300 ease-in-out"
style={{ gridTemplateRows: open ? '1fr' : '0fr' }}
>
<div ref={contentRef} className="overflow-hidden">
{children}
</div>
</div>
);
}
/** Format a unix timestamp as a relative or absolute date. */
function formatTxDate(timestamp?: number): string {
if (!timestamp) return 'Pending';
const date = new Date(timestamp * 1000);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) return 'Today';
if (diffDays === 1) return 'Yesterday';
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
/** Single transaction row. */
function TxRow({ tx, btcPrice }: { tx: Transaction; btcPrice?: number }) {
const isReceive = tx.type === 'receive';
return (
<Link
to={`/i/bitcoin:tx:${tx.txid}`}
className="flex items-center justify-between py-3 px-1 hover:bg-muted/50 transition-colors rounded-lg -mx-1 px-2"
>
<div className="flex items-center gap-3">
<div className={`flex items-center justify-center size-8 rounded-full ${
isReceive
? 'bg-green-500/10 text-green-600 dark:text-green-400'
: 'bg-red-500/10 text-red-600 dark:text-red-400'
}`}>
{isReceive
? <ArrowDownLeft className="size-4" />
: <ArrowUpRight className="size-4" />}
</div>
<div>
<p className="text-sm font-medium">{isReceive ? 'Received' : 'Sent'}</p>
<p className="text-xs text-muted-foreground">{formatTxDate(tx.timestamp)}</p>
</div>
</div>
<div className="text-right">
<p className={`text-sm font-medium ${
isReceive ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'
}`}>
{isReceive ? '+' : '-'}
{btcPrice
? satsToUSD(tx.amount, btcPrice)
: `${formatBTC(tx.amount)} BTC`}
</p>
<p className="text-xs text-muted-foreground">
{formatBTC(tx.amount)} BTC
</p>
</div>
</Link>
);
}