diff --git a/BITCOIN-SIGNING.md b/BITCOIN-SIGNING.md new file mode 100644 index 00000000..c1eba4d5 --- /dev/null +++ b/BITCOIN-SIGNING.md @@ -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 +``` + +**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: [""] +result: "" +``` + +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) diff --git a/NIP.md b/NIP.md index 971251de..b4bd822f 100644 --- a/NIP.md +++ b/NIP.md @@ -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": "", + "content": "Great post!", + "tags": [ + ["e", "", ""], + ["p", ""], + ["i", "bitcoin:tx:"], + ["amount", ""], + ["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:` where `` 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 `::`. Used instead of (or alongside) `e` for kinds 30000–39999. | +| `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": [""], "limit": 100 } +``` + +For addressable events, use `"#a": ["::"]` instead. For profile-level zaps, use `"#p": [""]`. + +**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 diff --git a/WALLET.md b/WALLET.md new file mode 100644 index 00000000..8e1be2f3 --- /dev/null +++ b/WALLET.md @@ -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: +``` + +### 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) diff --git a/package-lock.json b/package-lock.json index 1c60cfb8..ff797902 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index a73ebbb3..a2673a0a 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index db2322fb..370a7159 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -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() { } /> } /> + } /> } /> } /> } /> diff --git a/src/components/BitcoinContentHeader.tsx b/src/components/BitcoinContentHeader.tsx new file mode 100644 index 00000000..7ec72e08 --- /dev/null +++ b/src/components/BitcoinContentHeader.tsx @@ -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 ( + + ); +} + +/** 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 ; + + if (error || !tx) { + return ( +
+ +

Failed to load transaction

+

{txid}

+
+ ); + } + + return ( +
+ {/* Header */} +
+
+
+ {tx.confirmed ? : } +
+
+

+ {tx.confirmed ? 'Confirmed' : 'Unconfirmed'} +

+ {tx.blockTime && ( +

{formatBlockTime(tx.blockTime)}

+ )} +
+
+ + {/* Transaction ID */} +
+

Transaction ID

+
+

{tx.txid}

+ +
+
+ + {/* Stats grid */} +
+ {tx.confirmed && tx.blockHeight !== undefined && ( + } label="Block" value={formatNumber(tx.blockHeight)} /> + )} + } label="Size" value={`${formatNumber(tx.weight / 4)} vB`} /> + } + label="Fee" + value={`${formatSats(tx.fee)} sat`} + subtitle={`${(tx.fee / (tx.weight / 4)).toFixed(1)} sat/vB`} + /> + } + label="Amount" + value={`${formatBTC(tx.totalOutput)} BTC`} + subtitle={btcPrice ? satsToUSD(tx.totalOutput, btcPrice) : undefined} + /> +
+
+ + {/* Inputs → Outputs flow */} +
+ +
+ + {/* Footer: link to mempool.space */} + +
+ ); +} + +function StatCard({ icon, label, value, subtitle }: { icon: React.ReactNode; label: string; value: string; subtitle?: string }) { + return ( +
+
+ {icon} + {label} +
+

{value}

+ {subtitle &&

{subtitle}

} +
+ ); +} + +/** Inputs → Outputs visualization, mempool.space-style. */ +function TxFlow({ tx, btcPrice }: { tx: TxDetail; btcPrice?: number }) { + return ( +
+
+ {tx.inputs.length} Input{tx.inputs.length !== 1 ? 's' : ''} + + {tx.outputs.length} Output{tx.outputs.length !== 1 ? 's' : ''} +
+ +
+ {/* Inputs */} +
+ {tx.inputs.slice(0, 10).map((input, i) => ( + + ))} + {tx.inputs.length > 10 && ( +

+ +{tx.inputs.length - 10} more input{tx.inputs.length - 10 !== 1 ? 's' : ''} +

+ )} +
+ + {/* Outputs */} +
+ {tx.outputs.slice(0, 10).map((output, i) => ( + + ))} + {tx.outputs.length > 10 && ( +

+ +{tx.outputs.length - 10} more output{tx.outputs.length - 10 !== 1 ? 's' : ''} +

+ )} +
+
+
+ ); +} + +function TxInputRow({ input, btcPrice }: { input: TxInput; btcPrice?: number }) { + if (input.isCoinbase) { + return ( +
+
+ Coinbase + {formatBTC(input.value)} BTC +
+
+ ); + } + + return ( +
+
+ {input.address ? ( + + {truncateMiddle(input.address, 10, 6)} + + ) : ( + Unknown + )} + {formatBTC(input.value)} BTC +
+ {btcPrice !== undefined && ( +

{satsToUSD(input.value, btcPrice)}

+ )} +
+ ); +} + +function TxOutputRow({ output, btcPrice }: { output: TxOutput; btcPrice?: number }) { + const isOpReturn = output.scriptpubkeyType === 'op_return'; + + if (isOpReturn) { + return ( +
+ OP_RETURN +
+ ); + } + + return ( +
+
+ {output.address ? ( + + {truncateMiddle(output.address, 10, 6)} + + ) : ( + Unknown + )} + {formatBTC(output.value)} BTC +
+ {btcPrice !== undefined && ( +

{satsToUSD(output.value, btcPrice)}

+ )} +
+ ); +} + +function TxSkeleton() { + return ( +
+
+
+ +
+ + +
+
+
+ + +
+
+ + + + +
+
+
+ +
+
+ + +
+
+ + +
+
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// Bitcoin Address Header +// --------------------------------------------------------------------------- + +export function BitcoinAddressHeader({ address }: { address: string }) { + const { addressDetail, btcPrice, isLoading, error, refetch } = useBitcoinAddress(address); + + if (isLoading) return ; + + if (error || !addressDetail) { + return ( +
+ +

Failed to load address

+

{address}

+ +
+ ); + } + + return ( +
+ {/* Header */} +
+
+
+ +
+
+

Bitcoin Address

+

+ {addressDetail.txCount + addressDetail.pendingTxCount} transaction{(addressDetail.txCount + addressDetail.pendingTxCount) !== 1 ? 's' : ''} +

+
+
+ + {/* Address */} +
+

Address

+
+

{address}

+ +
+
+ + {/* Balance hero */} +
+

Balance

+

+ {btcPrice ? satsToUSD(addressDetail.totalBalance, btcPrice) : `${formatBTC(addressDetail.totalBalance)} BTC`} +

+

+ {formatBTC(addressDetail.totalBalance)} BTC +

+ {addressDetail.pendingBalance !== 0 && ( +

+ + {btcPrice + ? `${satsToUSD(addressDetail.pendingBalance, btcPrice)} pending` + : `${formatBTC(addressDetail.pendingBalance)} BTC pending`} +

+ )} +
+ + {/* Stats grid */} +
+ } + label="Total Received" + value={`${formatBTC(addressDetail.totalReceived)} BTC`} + subtitle={btcPrice ? satsToUSD(addressDetail.totalReceived, btcPrice) : undefined} + /> + } + label="Total Sent" + value={`${formatBTC(addressDetail.totalSent)} BTC`} + subtitle={btcPrice ? satsToUSD(addressDetail.totalSent, btcPrice) : undefined} + /> +
+
+ + {/* Recent Transactions */} + {addressDetail.recentTxs.length > 0 && ( +
+
+

+ Recent Transactions +

+
+
+ {addressDetail.recentTxs.slice(0, 10).map((tx) => ( + + ))} +
+ {addressDetail.recentTxs.length > 10 && ( +
+

+ {addressDetail.txCount - 10} more transaction{addressDetail.txCount - 10 !== 1 ? 's' : ''} +

+
+ )} +
+ )} + + {/* Footer: link to mempool.space */} + +
+ ); +} + +function AddressTxRow({ tx, btcPrice }: { tx: { txid: string; amount: number; type: 'receive' | 'send'; confirmed: boolean; timestamp?: number }; btcPrice?: number }) { + const isReceive = tx.type === 'receive'; + + return ( + +
+
+ {isReceive ? : } +
+
+

{isReceive ? 'Received' : 'Sent'}

+

{truncateMiddle(tx.txid, 8, 8)}

+
+
+
+

+ {isReceive ? '+' : '-'}{formatBTC(tx.amount)} BTC +

+ {btcPrice && ( +

+ {satsToUSD(tx.amount, btcPrice)} +

+ )} +
+ + ); +} + +function AddressSkeleton() { + return ( +
+
+
+ +
+ + +
+
+
+ + +
+
+ + + +
+
+ + +
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// 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 ( +
+
+ +
+ + +
+
+
+ ); + } + + const amount = tx ? tx.totalOutput : 0; + const fee = tx?.fee ?? 0; + + return ( + +
+ +
+
+
+ + Bitcoin Transaction + {tx && ( + + {tx.confirmed ? 'Confirmed' : 'Unconfirmed'} + + )} +
+

+ {tx ? `${satsToBTC(amount)} BTC` : truncateMiddle(txid, 12, 8)} + {tx && btcPrice ? ( + ({satsToUSD(amount, btcPrice)}) + ) : null} +

+ {tx && ( +

+ Fee {formatSats(fee)} sats + {tx.blockHeight ? ` · Block ${tx.blockHeight.toLocaleString()}` : ''} +

+ )} +
+ + + ); +} + +/** 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 ( +
+
+ +
+ + +
+
+
+ ); + } + + const balance = addressDetail?.totalBalance ?? 0; + const txCount = addressDetail ? addressDetail.txCount + addressDetail.pendingTxCount : 0; + + return ( + +
+ +
+
+
+ + Bitcoin Address +
+

+ {addressDetail ? `${satsToBTC(balance)} BTC` : truncateMiddle(address, 12, 8)} + {addressDetail && btcPrice ? ( + ({satsToUSD(balance, btcPrice)}) + ) : null} +

+ {addressDetail && ( +

+ {txCount.toLocaleString()} transaction{txCount !== 1 ? 's' : ''} + {' · '} + {truncateMiddle(address, 8, 6)} +

+ )} +
+ + + ); +} diff --git a/src/components/BookFeedItem.tsx b/src/components/BookFeedItem.tsx index 21b3b37b..600c4e73 100644 --- a/src/components/BookFeedItem.tsx +++ b/src/components/BookFeedItem.tsx @@ -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; diff --git a/src/components/CommentContext.tsx b/src/components/CommentContext.tsx index a9f6181f..16982806 100644 --- a/src/components/CommentContext.tsx +++ b/src/components/CommentContext.tsx @@ -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 = { 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; } + // Bitcoin transaction identifiers — show icon + truncated txid with hover preview + if (identifier.startsWith('bitcoin:tx:')) { + return ; + } + + // Bitcoin address identifiers — show icon + truncated address with hover preview + if (identifier.startsWith('bitcoin:address:')) { + return ; + } + // Generic fallback for other external identifiers const link = `/i/${encodeURIComponent(identifier)}`; @@ -996,3 +1009,69 @@ function GathererCardCommentContext({ ); } + +/** 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 ( + + + + + e.stopPropagation()} + > + transaction {truncated} + + + e.stopPropagation()} + > + + + + + ); +} + +/** 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 ( + + + + + e.stopPropagation()} + > + address {truncated} + + + e.stopPropagation()} + > + + + + + ); +} diff --git a/src/components/ExternalContentHeader.tsx b/src/components/ExternalContentHeader.tsx index 25775733..05894447 100644 --- a/src/components/ExternalContentHeader.tsx +++ b/src/components/ExternalContentHeader.tsx @@ -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 ; case 'iso3166': return ; + case 'bitcoin-tx': + return ; + case 'bitcoin-address': + return ; default: return ( diff --git a/src/components/LiveStreamPage.tsx b/src/components/LiveStreamPage.tsx index 7b0e7a63..e9b30fce 100644 --- a/src/components/LiveStreamPage.tsx +++ b/src/components/LiveStreamPage.tsx @@ -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 (