Skip to content

(ios) Playground: Bolt Card + Phoenix Wallet #665

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 2 commits into
base: master
Choose a base branch
from

Conversation

robbiehanson
Copy link
Contributor

@robbiehanson robbiehanson commented Jan 3, 2025

The Bolt Card allows for bitcoin payments over the lightning network using a contactless payment card.

This PR:

  • allows users to link a bolt card to their phoenix wallet
  • they can then make contactless payments at supporting merchants using their card
  • the user can manage their card within the app:
    • freeze / unfreeze the card at anytime
    • set daily / monthly spending limits
  • the user can link multiple cards to their account (e.g. mom links a card for her daughter, and sets spending limits for that card)

This is a playground / draft PR, with the goal of developing and testing Bolt Card version 2 - a new version that replaces LNURL with modern lightning network communication.

There's a LOT to explain here, so I've broken it down into sections.


User Experience

It's super easy to link a card to your wallet. Just tap the "create new debit card" button, and then tap the card to the upper-half of the iPhone.

nfc_write_720p.mov

After that the user is free to manage their card however they want:

When they make a payment with the card, they will see a notification on their phone:


NTAG 424 DNA

The NFC card that's used is called NTAG 424 DNA

This type of card can be used for many different things. But it also has the attributes needed to perform card payments. In particular, it has AES encryption plus a built-in counter that gets incremented everytime the card is read.

Here's the cliff notes version of how it works:

When you program the card, you write:
  1. AES encryption keys:
key_1 = 96aa8e8e921e82eda6a8e881472791b7
key_2 = 1e92ba49427e8e3e937c202182f047f3
  1. NDEF template string:
foo:bar?picc_data=00000000000000000000000000000000&cmac=000
0000000000000
  1. NDEF template settings:
piccOffet = 18
piccKey = key_1
cmacOffset = 56
cmacKey = key_2
Then when the card is read, it will:
  1. Increment it's internal counter variable
  2. Generate picc data:
picc = AES.encrypt(
  key = key_1,
  data = "${UID}${counter}${random_bytes}"
).toHex()
  1. Generate cmac (message authentication code):
cmac = AES.cmac(
  key = key_2,
  data = "${header}${UID}${counter}${padding}"
).toHex()
  1. Output NDEF result according to template string:
result = foo:bar?picc_data=6fbd71185a71b2fd29a5aa7b7006a8a3&cmac=f135ae3682f25dd7
Then general idea is:
  • The string generated by the card gets sent to the wallet, along with an invoice
  • Since the wallet knows the keys on the card, it can:
    • Decrypt the data and extract the card's UID & counter value
    • Verify the counter value has been incremented
    • Verify the CMAC
  • Based on all the information it has, it can decide whether or not to make the payment
Common misunderstanding:

The card does NOT have a static value. The card has a built-in counter that gets incremented automatically. So everytime the card is read, it will output a different value. And within the encrypted value is an incremented counter. This protects against replay attacks.


Lnurl-Withdraw

The Bolt Card was initially released several years ago. Long before Bolt 12 was standardized and widely deployed. Thus it's completely understandable that they opted to use lnurl-withdraw.

However, the use of lnurl-withdraw means:

  • An HTTP server is required
  • The server receives the invoice, and is trusted to forward it to the wallet

There's not many problems with this design if you're operating a custodial wallet service. But if you're designing a non-custodial wallet, then there are lots of problems. Thus the desire for an updated version that takes advantage of modern lightning technologies.

(Similar to how BIP-353 is replacing lnurl-pay for lightning addresses.)


Host Card Emulation (like Apple Pay)

It is my understanding that we do NOT need any special permission from Apple to allow either reading NFC cards, or writing to them within our app.

This is in stark contrast to doing Host Card Emulation, where the phone itself acts as an NFC card, and sends data to a reader (i.e. like when using Apple Pay)

  • In 2022, the EU accused Apple of abusing its dominant position in the smartphone market by restricting access to NFC technology, which is crucial for contactless payments. This restriction was seen as a way to favor Apple Pay over competing mobile payment services.
  • In 2024 Apple settled the case. They offered commitments to address these concerns, including providing third-party developers in the European Economic Area (EEA) with access to use NFC for contactless payments and transactions.

However, you must obtain special permission from Apple to use this technology. Here's the details for obtaining permission in the European Economic Area. And here's the details for obtaining permission in the USA.

However, note that even if Apple decides to give you permission to use the technology, it can only be used in an "eligible territory", which Apple decides. And there are more people in the world living outside these "eligible territories" than inside.


Task List:

  • Library to write to NFC cards
  • UI for linking debit cards
  • UI for managing debit cards
  • Logic to handle incoming payment requests
  • Push notifications for payment requests
  • Sync cards to iCloud
  • Design & implementation of version 2

@robbiehanson
Copy link
Contributor Author

  • bLIP: XX
  • Title: Bolt Card v2
  • Status: Draft
  • Author: Robbie Hanson
  • Created: 2025-03-13
  • License: CC0

Abstract

The Bolt Card allows for bitcoin payments over the lightning network using a contactless payment card. The original specification (hereby referred to as version 1) uses lnurl-withdraw. The problem with this is that an HTTP server is required, which creates a significant problem for self-custodial wallets. This bLIP provides a new specification (version 2) that does not require an HTTP server, and uses native lightning messaging.

Motivation

In the original version, a merchant who scans a bolt card will read a URL, which is an address for lnurl-withdraw. The merchant then basically generates a Bolt 11 invoice, and ends up sending it to the HTTP server, which is then trusted to forward it to the self-custodial wallet.

The wallet user must fully trust the HTTP provider, because it would be very easy to replace the merchant's invoice and steal funds. Providing this HTTP service may also introduce possible legal risks & complications.

But to put it simply: there's technically zero reason to use lnurl-withdraw anymore. With Bolt 12 standardized we now have all the tools we need. A merchant can send the invoice directly to the wallet via an onion message, routed over the lightning network itself. This removes the need for an HTTP server, while also increasing privacy for the wallet.

Protocol Overview

Bolt 12 introduces "offers", which encapsulate a way to communicate with another node over the lightning network. This is generally used to send an invoice-request (meaning offers can function as a reusable "address" for receiving payments). But we're going to use offers for a slightly different purpose.

Version 2 works by writing the wallet's offer to the Bolt Card. Thus, when the merchant reads the card they'll see:

  • the wallet's bolt 12 offer
  • plus the card's dynamic values (e.g. picc_data & cmac)

Since the merchant has a bolt 12 offer, this means they can send a message to the wallet directly over the lightning network. In this case the message will be a payment-request which will include:

  • an invoice generated by the merchant (e.g. to get paid for the coffee)
  • the card's dynamic values

The wallet will receive the onion message, and will validate the card's dynamic values. It can then decide whether to make the payment based on other factors (e.g. has card been frozen).

When the merchant receives the payment, it can associate that payment with the bolt 12 offer of the wallet. This allows the merchant software to issue a refund without requiring any interaction from the payer.

Bolt Card Content

For version 2, the value written to the bolt card must be a standard NDEF message with well-known type: TEXT. (The language identifier will be ignored. It's recommended to use "en".)

The text content can either be:

  • a Bolt 12 offer, or
  • a BIP-353 address, which must resolve to a Bolt 12 offer
Bolt 12 offer

If writing an offer to the card, the text must start with "lno". Thus the format of the read value should be:

lno<...>?query=parameters&go=here
  • the offer must be in all lowercase
  • the offer must not contain whitespace or + characters
  • the offer must be separated from the query parameters using the ? character

While an offer is generally preferable to a bip-353 address, it might not always be possible. Specifically, the card only has 256 bytes of storage for the template string. But you'll need 2 bytes for the NDEF filesytem header, and 7 bytes for the NDEF message header. Plus you'll also need space for the picc_data & cmac. Using the example above this is another 65 bytes. So if the offer contains a blinded path, it may not fit.

BIP-353 address

If writing a bip-353 address to the card, the text must start with "₿". Thus the format of the read value should be:

₿<...>?query=parameters&go=here
  • E.g. [email protected]?picc_data=abc123&cmac=def456
  • The address must be a valid bip-353 address
  • It must resolve to a bolt 12 offer
  • the address must be separated from the query parameters using the ? character
Query parameters

The "query parameters" must not be an empty string (must have at least one character). But other than that, this specification does not enforce the format of that data. It's up to the wallet to decide.

For example, you could follow the example from above:

picc_data=6fbd71185a71b2fd29a5aa7b7006a8a3&cmac=f135ae3682f25dd7

or change the names:

p=6fbd71185a71b2fd29a5aa7b7006a8a3&c=f135ae3682f25dd7

or even just squish it all together without using the common "query parameters" format:

6fbd71185a71b2fd29a5aa7b7006a8a3f135ae3682f25dd7

Scanning a Bolt Card

When the merchant scans the bolt card, they should expect to find either V1 or V2. Version 1 is a NDEF message with well-known type: URI. While version 2 is a NDEF message with well-known type: TEXT.

For version 2, the TEXT must either start with "lno" or "₿". Otherwise the card must be treated as a non-bolt-card.

Hybrid version

For wallets that already support V1, they can start to support V2 by adding a "v2" query parameter to the URL. For example:

https://bolt.wallet.io/123?v2=₿[email protected]&picc=abc123&cmac=def456

The v2 parameter value must be as described above (either a bolt 12 offer, or a bip-353 address). Merchant software that supports V2 will then:

  • extract the V2 parameter value
  • concatenate the other parameter values in the standard format

For example, from the URL above:

[email protected]?picc=abc123&cmac=def456

Merchant processing

If the V2 value is a BIP-353 address, then the first step will be to resolve that address to a Bolt 12 offer.

Once the merchant has the offer, it will then generate an unsolicited Bolt 12 invoice. That is, an invoice not based on an invoice_request.

  • The invoice must include invoice_amount TLV (as required by the spec)
  • It must include all of the invoice_* * offer_* TLV values as defined in the spec
  • The only invreq_* TLV that is allowed is invreq_chain, which should ONLY be set for testnet (never for mainnet).

One additional TLV is added to unsolited invoice:

  1. type: 1_000_298_424 (card_params)
  2. data:
    • [...*byte:parameters_string_as_utf8_data]

Where the value is the extracted query parameters. E.g.:

picc_data=6fbd71185a71b2fd29a5aa7b7006a8a3&cmac=f135ae3682f25dd7

Note that this value should be treated as a generic string. The merchant shouldn't make any assumptions concerning it's format. The data must be encoded as UTF-8.

The merchant then generates an onion message, where the final-hop is the owner of the offer. The onionmsg_tlv is:

  1. tlv_stream: onionmsg_tlv
  2. types:
    1. type: 166 (payment_request)
    2. data:
      • [tlv_invoice:inv]

That is, the value for the final hop is the unsolicted invoice, including the card_params TLV item. The merchant then sends this onion message to the owner of the offer.

Recommendations

It's recommended that the merchant software store the base value that was read from the card (either the offer or bip-353 address), and associate this information with the received payment. This allows the merchant software to issue a refund without requiring any interaction from the payer. Thus alleviating a common pain point for merchants (and upset customers).

Client processing

When the client receives the payment_request it should:

  • decrypt the picc_data
  • verify the UID
  • verify the counter has been incremented
  • verify the cmac value
  • perform custom checks such as:
    • has the user frozen the card
    • has the user exceeded a set spending amount for the card

If all checks pass, it's free to send the payment for the Bolt 12 invoice.

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.

1 participant