Skip to content

feat: nfc and bluetooth nearby payments - #1883

Draft
jvsena42 wants to merge 4 commits into
masterfrom
feat/nfc-payments
Draft

jvsena42 wants to merge 4 commits into
masterfrom
feat/nfc-payments

Conversation

@jvsena42

@jvsena42 jvsena42 commented Nov 12, 2026 •

Copy link
Copy Markdown
Member

Closes #1740

Twin: synonymdev/bitkit-ios#1302

This PR adds NFC and Bluetooth nearby payments. Payment scanners read requests from NFC tags, payment terminals and nearby Bitkit wallets. The Receive screen offers its request to nearby phones. The protocol is in docs/nearby-payments.md and is shared with iOS.

Platform support

Android iOS
Pay by reading an NFC tag or terminal Yes, picked up automatically while a payment scanner is open Yes, but the user taps the scanner pill or NFC button to start a read
Be tapped over NFC on Receive Yes, the phone acts as a tag No, Apple only allows this for licensed payment providers
Bluetooth nearby, paying side Yes Yes
Bluetooth nearby, receiving side Yes Yes, the only way an iPhone can receive from a nearby phone
"SCAN OR TAP TO PAY" on Receive Yes Shown while Bluetooth is advertising
TapPay (NFC) intro sheet, "Nearby payments" setting, scanner states from Figma Yes Yes

Description

  • NFC read: the Send recipient camera, the main scanner sheet, the Send QR scanner and the USDT send scanner put the phone in NFC reader mode while they are open.
    • A tag's URI, Text or absolute-URI record goes through the same scan pipeline as a QR code, so QuickPay applies as the design asks.
    • Accepted: lightning:, BIP21 bitcoin: (with lightning=), LNURL, and USDT ethereum:.
  • NFC tap to pay (receive): while Receive is in the foreground and the app is unlocked, NdefTagService (host card emulation) serves the current request as a read-only NFC Forum Type 4 tag (category other, device unlock required). It also covers the USDT receive screen.
    • The NDEF tag AID D2760000850101 is registered for the service only while Receive is shown, and the service is the preferred one meanwhile.
    • With nothing published, the service answers the application SELECT with "not found", so readers elsewhere see no tag.
  • Bluetooth (both directions):
    • Receive runs a GATT server and advertises the nearby service UUID only, with no device name.
    • Payment scanners scan for that UUID, connect to the strongest phone at or above -60 dBm, and read the request in 512-byte chunks.
    • Requests that arrive over Bluetooth always open the normal review. QuickPay is suppressed, because Bluetooth range lets a phone that isn't the merchant's answer.
    • Only payment requests are accepted over Bluetooth: bitcoin:, a lightning: invoice or offer, and USDT ethereum:. Anything else the scanner understands (Pubky auth, LNURL auth/withdraw/channel, wallet connection setups, contact keys) is ignored, and that device is skipped.
    • Both sides stop while the app is locked, and restart when Bluetooth or location is switched on or off.
    • Android 9–11: scanning reports location services off, with a button to the Location settings, since scans return nothing without it. Advertising needs no location permission.
  • Scanner states (Figma):
    • The pill reads "Scanning QR, NFC & Bluetooth", "Scanning QR & NFC", "Scanning QR & Bluetooth" or "Scanning QR", depending on active sources.
    • Bluetooth and NFC buttons appear when a source needs a permission or is off. They request the Nearby devices permission, turn on Bluetooth, or open NFC settings.
    • When the camera is denied, every payment scanner shows "Scan to pay" with camera, Bluetooth and NFC buttons.
  • Receive: "Scan or tap to pay" appears under Edit/Copy/Share while NFC emulation or Bluetooth advertising is active.
  • TapPay (NFC) intro: a one-time timed sheet on devices with NFC.
    • It has the NFC illustration from Figma, a QuickPay toggle, and Settings / Awesome! buttons.
    • Dismissing it also marks the QuickPay intro as seen, since it carries the same toggle.
  • Setting: "Nearby payments" (default on) under Settings > General > Payments turns Bluetooth advertising on Receive on or off.
  • Manifest: NFC, BLUETOOTH_ADVERTISE, optional nfc and nfc.hce features, and the HCE service.
  • Translations: all new strings in the 15 locales.

POS request formats

  • Square Register (2nd gen) serves lightning:<uppercase BOLT11> as one long-format NDEF URI record (header 0xC1, identifier code 0x00). Bitkit decodes it as is. Tests cover it with a real 447-character signet invoice, a 465-byte message laid out like Square's.
  • Numo, and Square in a later release, serve BIP 321 without an address: bitcoin:?lightning=<BOLT11>. bitkit-core rejects that form (Decode BIP 321 URIs without an address (bitcoin:?lightning=…) bitkit-core#212), so requests read over NFC or Bluetooth are rewritten to lightning:<invoice> before decoding. The rewrite can go once core supports BIP 321.

Out of Scope

  • nfc: being read by Bolt Card terminals (Blink, BTCPay, LNbits TPoS, Swiss Bitcoin Pay, Wallet of Satoshi merchant mode). They read an lnurlw card and pull the payment, which needs a server-side LNURL-withdraw relay for a self-custodial wallet.
  • nfc: USDT over NFC terminals. None exist on any chain, Arbitrum included. A USDT ethereum: request read from another Bitkit is supported.
  • nearby: requesting the Nearby devices permission from Receive. Receive advertises only once the permission was granted from the scanner, because the design has no Receive permission state.
  • settings: the design's "QRs + NFC" QuickPay row value. The row keeps On/Off.

Design

NFC compatibility (research, Nov 2026):

Terminal / wallet Direction Supported here
Square Register (2nd gen) Buyer taps the Register; payload unpublished, inferred to be a lightning: Type 4 tag Read path; unverified against a real Register
Numo, ZEUS tap-to-pay, static lightning:/bitcoin:/LNURL tags Phone reads the tag Yes
Another Bitkit (Android) on Receive Phone reads the emulated tag Yes
Bolt Card terminals (Blink, BTCPay, LNbits, Swiss Bitcoin Pay, WoS merchant) Terminal reads an lnurlw card No, see Out of Scope
Bitfinex Pay Web checkout, no NFC N/A

Preview

Screenshots are from an emulator, which has no NFC hardware. For these captures only, the NFC availability checks were forced on in a local build. That override is not in this branch. The Bluetooth states are real.

TapPay intro Scan to pay (camera denied) Bluetooth not allowed
TapPay intro Scan to pay Bluetooth button
Scanning QR, NFC & Bluetooth Receive: scan or tap to pay Nearby payments setting
Scanning Receive Setting

QA Notes

Setup

  • Regtest dev build (devDebug) from this branch on two NFC-capable Android phones, a third phone or a tag writer for static tags, and an iPhone running feat: nfc and bluetooth nearby payments bitkit-ios#1302 for the cross-platform cases.
  • Fund the paying wallet's spending balance through the Blocktank regtest API, so QuickPay cases can pay.
  • NFC on in system settings; Bluetooth on.

Journeys

  • J1: new nearby-payments-toggle.xml — Settings shows Nearby payments on by default, and the choice persists
  • J2: new scanner-nearby-states.xml — the Send scanner shows Scan to pay when the camera is denied, a Bluetooth button and "Scanning QR" without the Nearby devices permission, then "Scanning QR & Bluetooth"

NFC and Bluetooth between devices are not in the Capabilities table; see Manual Tests.

Manual Tests

  • 1. Phone A on Receive (Spending tab), phone B opens Send and holds near A → B opens the review for A's invoice — NFC between devices
    • 1a. Same with QuickPay on and an amount under the threshold → B pays without review — NFC between devices
    • 1b. Same with an amount above the threshold → B shows the review — NFC between devices
  • 2. Phone B taps a written lightning: tag, a bitcoin:?lightning= tag and an LNURL-pay tag from the main scanner → each opens the matching send flow — NFC tags
  • 3. Phone A on Receive with USDT enabled (USDT tab), phone B on the USDT send scanner taps A → B fills A's USDT address — NFC between devices
  • 4. Phone B taps a tag with no payment request → toast "Unable To Read NFC" — NFC tags
  • 5. Phone A on Receive, phone B opens Send within about 30 cm → B opens the review for A's request. QuickPay does not pay automatically, even under the threshold — Bluetooth between devices
  • 6. Phone A turns off Settings > Nearby payments → B no longer picks A up over Bluetooth, and A's Receive hides "Scan or tap to pay" when NFC is also off — Bluetooth between devices
  • 7. Android Receive → iPhone pays over Bluetooth, and iPhone Receive → Android pays over Bluetooth — Bluetooth across platforms
  • 8. iPhone (bitkit-ios#1302) taps Android Receive over NFC → iPhone opens the review — NFC across platforms
  • 9. Fresh install on an NFC phone → TapPay intro appears once on Home. The QuickPay toggle changes the QuickPay setting, Settings opens QuickPay settings, and the QuickPay intro does not show afterwards — timed sheet on device with NFC
  • 10. Send scanner with Nearby devices denied and NFC off → Bluetooth and NFC buttons show, and each enables its source and updates the pill — device permissions
  • 11. Square Register (2nd gen) in bitcoin tender → tapping phone B opens the review for the Register's invoice — Square hardware
  • 12. A device serving a non-payment request over the nearby service (e.g. a pubkyauth:// URI from a test build) → B's scanner ignores it and opens nothing — Bluetooth between devices
  • 13. Phone A leaves the app with Receive open, then returns to the PIN screen → while locked, B reads nothing from A over NFC or Bluetooth; after unlocking, B reads A's request — NFC and Bluetooth between devices
  • 14. Android 9–11 phone with location services off → the Send scanner shows the Bluetooth button, which opens Location settings; after turning location on, the pill includes Bluetooth — Android 9–11 device

Automated Checks

  • added NdefCodecTest.kt — URI/Text/absolute-URI records, prefix codes, long records, malformed input, and single-request selection
  • added Type4TagEmulatorTest.kt — the Type 4 APDU read sequence, chunked reads, no tag while nothing is published, read-only, and errors
  • added NfcPaymentUriTest.kt — schemes added to bare invoices and addresses
  • added NearbyProtocolTest.kt — UUIDs, meta encoding, chunking and reassembly, offset reads, and limits
  • added NfcTimedSheetTest.kt — shows once and only with NFC, and marks both intros seen
  • added NearbyRequestFilterTest.kt — Bluetooth accepts bitcoin, lightning invoice/offer and ethereum requests, and refuses auth, LNURL, setup and contact payloads
  • added NfcTagEmitterTest.kt — an older publisher cannot clear a newer request
  • updated AppViewModelSendFlowTest.kt — new timed sheet dependency

Evidence

On 28639c4e7:

  • just compile, just test and just lint (detekt and Android Lint) all pass.
  • Two-emulator Bluetooth check repeated after the review fixes: B read A's request (Read nearby request, then Handling decoded scan data).

On 40b2a250b:

  • Two Android 16 emulators (medium_phone and Pixel_9) running regtest devDebug:
    • Bluetooth: emulator A on Receive advertised the service UUID, connectable and with no name, per dumpsys bluetooth_manager.
    • Emulator B's Send recipient screen connected to it and read the unified request (about 450 bytes, two chunks). The log shows Read nearby request and then Handling decoded scan data: OnChain(... lightning=lnbcrt1...). That covers case 5's transport, but not a completed payment, because B had no funds.
  • The screenshots above match the linked frames.

Gaps

  • Cases 1–4 and 8–11 were not run: emulators have no NFC, and no physical NFC phones or tags were available.
  • Case 5 was run only up to the scan being handled, and 6–7 were not run. iOS needs a device, because the simulator has no Bluetooth.
  • Case 11: Square's NFC payload is unpublished, so the Register path is unverified.
  • J1 and J2 have not been driven yet.
  • Cases 12–14 were not run (no second device serving a crafted payload, no NFC hardware, no Android 9–11 device).
  • The dynamic AID registration (the NDEF AID added only while Receive is shown) is unverified on hardware; case 1 covers it.
  • An independent review ran on 3e377fbf9, and its findings were addressed in 28639c4e7. One finding was not taken: it flagged that showsTapToPay comes after modifier in ReceiveQrView, but AGENTS.md requires modifier to be the first optional parameter.
  • Nearby payments stays on by default, matching iOS and the shared spec.

Models used

  • Planning/scoping: claude-opus-5-5 (reasoning: Not exposed)
  • Implementation: claude-opus-5-5 (reasoning: Not exposed)
  • Review: claude-opus-5-5 (reasoning: Not exposed)
  • Review rounds: 1

Metadata note: the session exposes the model ID but not the reasoning setting.

🤖 Generated with Claude Code

Payment scanners read requests from NFC tags (reader mode) and from nearby
Bitkit wallets over Bluetooth LE. The Receive screen offers its request as an
emulated NFC Forum Type 4 tag and over a BLE GATT service while it is on
screen. Adds the TapPay (NFC) intro sheet, the scanner source states from
Figma and a Nearby payments setting. Protocol in docs/nearby-payments.md.

Refs #1740

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jvsena42 jvsena42 self-assigned this Nov 12, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

Regtest APK

Built from 1dffebb (run).

Download bitkit-dev-debug universal APK (expires in 30 days).

- Accept only bitcoin:, lightning: invoice/offer and ethereum: requests over
  Bluetooth; skip the device otherwise.
- Stop offering and listening while the app is locked.
- Show the Scan to pay panel on every payment scanner when the camera is denied.
- Android 9-11: report location services off for scanning, and advertise
  without the location permission.
- Recreate the scanner/advertiser when Bluetooth or location is switched.
- Keep scanning after a delivered request, retry failed scans, and ignore
  callbacks from stale read sessions.
- Register the NDEF tag AID only while Receive is shown and present no tag
  when nothing is published; owner tokens for the emitter and reader mode.
- Translations, journeys, and repo conventions (nowMillis, Duration,
  Context.bluetoothManager, narrow lint suppressions, log wording).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Square Register serves lightning:<uppercase BOLT11> as a long-format NDEF
URI record; Numo serves BIP 321 without an address. Add a real 447-char
signet fixture and tests for record decoding, byte-exact encoding,
chunked tag reads and the Bluetooth allowlist.

bitkit-core rejects bitcoin:?lightning=<invoice> (synonymdev/bitkit-core#212),
so requests read over NFC or Bluetooth are rewritten to lightning:<invoice>.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: nfc and bluetooth payments

1 participant