Skip to content

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 b12 - an additional specification that uses 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 bolt-card-b12

@robbiehanson
Copy link
Contributor Author

robbiehanson commented May 22, 2025

Bolt Card - Bolt 12 Specification

Abstract

The Bolt Card allows for bitcoin payments over the lightning network using a contactless payment card. The original specification uses lnurl-withdraw. The problem with this is that an HTTP server is required, which creates a significant problem for self-custodial wallets. This document provides an additional specification 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 generates a Bolt 11 invoice, and sends 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 complications.

But to put it simply: there's technically no 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.

For a quick overview of Bolt 12, see section:

  • Cliff Notes: Bolt 12

Overview

When a merchant scans a bolt card, the card will output a dynamic value that changes everytime the card is read.

For more information about how this works, see section:

  • Technical Background: NTAG 424 DNA

For example, the value may look like this:

lnurlw://card.domain.com?picc_data=6fbd71185a71b2fd29a5aa7b7006a8a3&cmac=f135ae3682f25dd7

This can be broken down into the static part, and the dynamic part:

  • static = lnurlw://card.domain.com
  • dynamic = picc_data=6fbd71185a71b2fd29a5aa7b7006a8a3&cmac=f135ae3682f25dd7

The static part tells the merchant how to communicate with the associated wallet.

The dynamic part contains encrypted data that can only be read by the wallet.

The general idea for payments is:

  • The merchant generates an invoice
  • The merchant uses the static component to send both the invoice & the dynamic part to the wallet
  • Since the wallet knows the keys on the card, it can decrypt the data (i.e. extract the card's UID & counter, plus verify the CMAC)
  • Based on all the information it has, the wallet can decide whether or not to make the payment

The original specification (hereby referred to as bolt-card-lnurl) states that the static part must be a lnurl-withdraw URL. This additional specification (hereby referred to as bolt-card-b12) states that the static part may also be a Bolt 12 offer. Which allows the merchant to directly send the invoice & dynamic parameters directly to the wallet over the lightning network.

Dual Specification

This is an additional specification. As in, a bolt card could have either a lnurl-withdraw url, or a bolt 12 offer. And a merchant should support both.

Bolt Card Content

For bolt-card-lnurl, when the card is read, it should output a NDEF message with well-known type: URI. And the value of the URI is expected to be a lnurl-withdraw address.

For bolt-card-b12, the card MUST output a 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 NTAG 424 DNA card only has 256 bytes of storage for the NDEF file (where the template string goes). 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. ₿alice@wallet.io?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 bolt-card-lnurl or bolt-card-b12. The lnurl version is a NDEF message with well-known type: URI. While the b12 version is a NDEF message with well-known type: TEXT.

For the b12 version, 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 bolt-card-lnurl, they can start to support bolt-card-b12 by adding a "b12" query parameter to the URL. For example:

https://bolt.wallet.io/123?b12=₿alice@wallet.io&picc=abc123&cmac=def456

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

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

For example, from the URL above:

₿alice@wallet.io?picc=abc123&cmac=def456

General Idea

A Bolt 12 offer encodes a way to send a message to the associated wallet directly over the lightning network. Thus, when you write an offer onto a card (or an address that resolves to an offer), you make it possible for the merchant to send a message directly to the wallet.

The merchant is going to send a CardPaymentRequest message which will include:

  • the dynamic parameters from the scanned card
  • an invoice generated by the merchant
  • the merchant's own offer, which allows the wallet to respond with an error message (if needed)

Merchant processing

If the 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
  • It must include all of the invoice_* TLV values as defined in the spec
  • It must include all of the offer_* TLV values as defined in the spec. (These values will refer to the merchant's offer. Not the offer from the card.)
  • Since it's an unsolicited invoice, the only invreq_* TLV that is allowed is invreq_chain, (which is only set for testnet; not needed 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 destination is derived from the scanned offer. The onionmsg_tlv is:

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

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 wallet.

At this point, the merchant expects one of the following to happen:

  • the wallet sends a payment for the invoice
  • the wallet sends an error message back to the merchant
  • the wallet doesn't respond, and the invoice times out
Merchant Recommendations

Card payments are expected to be fast, and complete within a few seconds. So it's recommended that the generated invoice has a short timeout period. 30 seconds is the recommended value. This allows the merchant UI to display a countdown, which helps manage expectations. It's recommended to display the countdown about 10 or 15 seconds after the payment request has been sent.

If a payment is received, 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 CardPaymentRequest it should:

  • decrypt the picc_data
  • verify the UID
  • verify the counter has been incremented
  • verify the cmac value
  • perform other checks such as:
    • has the user frozen the card
    • has the user exceeded a set spending amount for the card
    • are there sufficient funds in the wallet to make the payment

If all checks pass, it can send the payment for the Bolt 12 invoice. If the payment succeeds, the client should NOT respond to the invoice request in any other way.

However, if the client is unable to send the payment, it must respond with a CardPaymentResponse as described below.

Error Messages

The error message, sent from the client to the merchant, allows the merchant to immediately know the payment failed, without having to wait for a timeout. Each error message should contain an error code and a message. The standardized error codes are listed below.

  • 1 = unknown card
  • 2 = replay detected
  • 3 = card is frozen
  • 4 = limit exceeded
  • 5 = bad invoice
  • 6 = already paid invoice
  • 7 = payment pending
  • 8 = internal error
  • 9 = insufficient funds

The client MAY send an error code not listed above, and the software should be equipped to handle such a case.

Note that care should be taken to avoid leaking too much information. For example, the wallet software may allow the user to set limits such as:

  • maximum amount per transation
  • daily spending limit
  • monthly spending limit

However, if one of these limits is breached, it's not advisable to tell the merchant which one exactly. Doing so would simply make it easier for a thief to steal more.

The client uses the merchant's offer (received via the CardPaymentRequest message) to send the error response.

The client generates an onion message, where the onionmsg_tlv is:

  1. tlv_stream: onionmsg_tlv
  2. types:
    1. type: 168 (card_payment_response)
    2. data:
      • [``tlv_invoice_error:inverr`]

That is, the content is a TLV stream containing a standard invoice_error as defined in Bolt 12. Both the error code and message go into the error (type 5) TLV item, with the error code at the beginning:

  • "{error_code}:{message}"

For example:

  • "3:card is frozen"
  • "9:insufficient funds"
Merchant Recommendations

When sending the CardPaymentRequest, the merchant will include an offer. This offer can contain a path_id in the Blinded Route. And this path_id value will be returned in the CardPaymentResponse. It's recommended the path_id contain an identifier that corresponds to the CardPaymentRequest. This way each response can be mapped to a specific request. And the merchant software won't get confused by spurious/late error responses.


Cliff Notes: Bolt 12

Bolt 12 is an upgrade to the lightning network that includes general "onion messages" and "reusable payment requests". It's not just a proposal - it was standardized and approved in 2024, and is already available & shipping in many lightning products.

Onion message routing

Originally, the lightning network only routed payments. But now you can send generalized "onion messages", which are routed through the lightning network, but are specifically designed for communication that is separate from the actual payment itself.

Offers

An "offer" (standaridized as part of Bolt 12) can be thought of as a reusable payment address.

Let's say that Bob's wallet supports Bolt 12 offers. Bob can send a single offer to all his friends. He can post it on the Internet. And everybody can use that single offer to securely pay him. As many times as they want.

So how does an offer work? It's quite simple:

  • the offer simply encodes a way to send a message to Bob directly over the lightning network
  • So let's say that Alice wants to send a payment to Bob using his offer. Her wallet will:
    • send an InvoiceRequest message to Bob's wallet
    • Bob's wallet replies with the Invoice
    • and Alice's wallet then sends the payment as usual

So both the InvoiceRequest and Invoice are onion messages that get sent over the network. And the format of each message is standardized as part of Bolt 12, allowing different wallets to communicate.

The bolt-card-b12 specification (described in this document) is quite similar. We specify two onion messages named CardPaymentRequest and CardPaymentResponse which are used to communicate between the merchant and client/wallet.


Technical Background: NTAG 424 DNA

The physical "bolt card" is actually a NFC card, spefically 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.

In other words, the card can output a dynamic value that changes everytime the card is read. For example, if you tap the card to your phone, it might read:

https://phoenix.acinq.co/lnurl?picc_data=6fbd71185a71b2fd29a5aa7b7006a8a3&cmac=f135ae3682f25dd7

And if you tap it again, the output will be slightly different:

https://phoenix.acinq.co/lnurl?picc_data=4a9a3bf97f22a10f3f840251adbcdb91&cmac=441aa8d8d01f5e4b

Here's the cliff notes version of how that 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:
foo:bar?picc_data=6fbd71185a71b2fd29a5aa7b7006a8a3&cmac=f135ae3682f25dd7

Next time the output will be different because the counter value will be different.

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.

2 participants