Fungible tokens: payment flows
✏ 2022-09-21 ✂ 2022-09-21- Introduction
- Prerequisites
- Invoice account
- Approve-transfer-from
- Transfer-notify
- Transfer-fetch
- Conclusion
Introduction
In the previous article, Fungible tokens 101, I introduced the concept of a ledger and various extensions that can help us solve practical problems. In this article, we shall analyze a few payment flows—protocols built on top of a ledger allowing clients to exchange tokens for a service—in the context of the Internet Computer.
Prerequisites
The payment scenario
Abstract protocols can be dull and hard to comprehend, so let us model a specific payment scenario: me buying a new laptop online and paying for it in wxdr (wrapped sdr) tokens locked in a ledger hosted on the Internet Computer.
I open the website of the hardware vendor I trust, select the configuration (the memory capacity, the number of cores, etc.) that suits my needs, fill in the shipment details, and go to the payment page. I choose an option to pay in wxdr.
In the rest of the article, we will fantasize about what the payment page can look like and how it can interact with the shop.
Participants
Each flow will involve the following participants:
Me: a merry human sitting in front of a computer and ordering a new laptop. | |
Shop: an Internet Computer smart contract accepting orders. | |
Web page: a spaghetti of markup, styling, and scripts serving the shop UI. | |
Wallet: a trusty hardware wallet device, such as Ledger or Trezor, with a corresponding UI for interacting with the ledger, such as Ledger Live. A more sophisticated wallet can smoothen the UX, but the ideas remain the same. | |
Ledger: an Internet Computer smart contract processing payments. |
Payment phases
All the payment flows we will analyze have three phases:
- The negotiation phase. After I place my order and fill in the shipment details, the shop creates a unique order identifier, Invoice ID. The web page displays the payment details (e.g., as a qr code of the request I need to sign) and instructions on how to proceed with the order.
- The payment phase. I use my wallet to execute the transaction as instructed on the web page. This phase is essentially the same in all flows; only the transaction type varies.
- The notification phase. The shop receives a payment notification for the Invoice ID, validates the payment, and updates the order status. The web page displays an upbeat message, completing the flow.
Invoice account
The first payment flow we will analyze relies on the subaccounts ledger feature.
The idea behind the flow is quite clever: the shop can use its subaccount identified by the Invoice ID as a temporary cell
for the payment.
I can transfer my tokens to this cell, and the shop can move tokens out because the cell belongs to the shop.
The happy case of the flow needs only one primitive from the ledger, the transfer
method specified below.
service : {
// Transfers token amount from the account of the (implicit) caller
// to the account specified by the principal and the subaccount.
// Arguments:
// amount - the token amount to transfer.
// from_subaccount - the subaccount of the caller to transfer tokens from.
// to - the receiver of the tokens.
// to_subaccount - which subaccount of the receiver the tokens will land on.
transfer(record {
amount : nat;
from_subaccount : opt blob;
to : principal;
to_subaccount : opt blob;
}) -> (TxReceipt);
}
The flow proceeds as follows:
-
In the negotiation phase, the webpage instructs me to transfer tokens to the shop’s Invoice ID subaccount and displays a big green
Done
button that I need to press after the payment succeeds. -
In the payment phase, I use my wallet to execute the
transfer({ amount = Price, to = Shop, to_subaccount = InvoiceId})
call on the ledger. -
In the notification phase, I click on the
Done
button dispatching a notification to the shop indicating that I paid the invoice (the webpage can remember the Invoice ID on the client side, so I do not have to type it in). Upon receiving the notification, the shop attempts to transfer the amount from its Invoice ID subaccount to its default account, callingtransfer({ amount = Price - Fee, from_subaccount = InvoiceID, to = Shop })
on the ledger. If that final transfer succeeds, the order is complete.
The invoice account flow has a few interesting properties:
- The ledger must process at least two messages: one transfer from me and another from the shop.
- Two transfers mean that the ledger charges two fees for each flow: one from me and another from the shop.
-
The ledger needs to remember one additional
(principal, subaccount, amount)
tuple for the duration of the flow. The tuple occupies at least 70 bytes. - The flow supports unlimited concurrency: I can make multiple payments to the same shop in parallel as long as each payment uses a unique invoice identifier.
- The ledger implementation is straightforward: the subaccounts feature is the only requirement for the flow.
What happens if I transfer my wxdrs but never click the Done
button?
Or what if my browser loses network connection right before it sends the shop notification?
The shop will not receive any notifications, likely never making progress with my order.
One strategy that the shop could use to improve the user experience in such cases is to monitor balances for unpaid invoices and complete transactions automatically if the notification does not arrive in a reasonable amount of time.
Approve-transfer-from
The approve-transfer-from pattern relies on the approvals ledger feature, first appearing in the ERC-20 token standard.
The flow uses two new ledger primitives, approve
and transfer_from
, and involves three parties:
- The owner holds tokens on the ledger. The owner can approve transfers from its account to a delegate.
- The delegate can transfer tokens from the owner’s account within the approved cap.
- The beneficiary receives tokens from the delegate as if the owner sent them.
In our scenario, the delegate and the beneficiary are the same entity—the shop.
We can capture the required ledger primitives in the following Candid interface:
service : {
// Entitles the delegate to spend at most the specified token amount on behalf
// of the (implicit) caller.
// Arguments:
// amount - the cap on the amount the delegate can transfer from the caller’s account.
// delegate - the actor entitled to make payments on behalf of the caller.
approve(record {
amount : nat;
delegate : principal;
}) -> ();
// Transfers the specified token amount from the owner account to the
// specified account.
// Arguments:
// amount - the token amount to transfer.
// owner - the account to transfer tokens from.
// to - the receiver of the tokens (the beneficiary).
//
// PRECONDITION: the owner has approved at least the amount to the (implicit) caller.
// POSTCONDITION: the caller’s allowance decreases by the amount.
transfer_from(record {
amount : nat;
owner : principal;
to : principal;
}) -> (nat) query;
}
The flow proceeds as follows:
-
In the negotiation phase, the webpage instructs me to approve a transfer to the shop, displaying the shop’s account.
One difference from the invoice account flow is that the shop needs to know my wallet’s address on the ledger to make a transfer on my behalf.
The webpage displays a text field for my account and the familiar
Done
button. - In the payment phase, I use my wallet to execute the
approve({to = Shop, amount = Price})
call on the ledger. -
In the notification phase, I paste my ledger address into the text field and press the button.
Once the shop receives the notification with my address and the Invoice ID, it executes
transfer_from({ amount = Price; owner = Wallet; to = Shop })
call on the ledger. If that transfer is successful, the order is complete.
Let us see how this flow compares to the invoice account flow:
- The ledger must process at least two messages: approval from the owner and a transfer from the shop.
- The ledger charges two fees for each payment: one for my approval and another for the shop’s transfer.
-
The ledger needs to remember one additional
(principal, principal, amount)
tuple for the duration of the flow. The tuple occupies at least 68 bytes. - The flow does not support concurrency: if I execute two payments to the same shop asynchronously, only one of the payments will likely succeed (the exact outcome depends on the message scheduling order).
- The ledger needs to maintain a data structure to track allowances, adding complexity to the implementation.
- I still have the tokens on my account if the shop never gets the notification due to a bug or a networking issue.
One strong side of the approve-transfer-from flow is that it supports recurring payments. For example, if I were buying a subscription with monthly installments, I could have approved transfers for the entire year, allowing the shop to transfer from my account once a month. Of course, I must trust the shop not to charge the whole yearly amount in one go.
Transfer-notify
Note that the failure of the frontend to send a notification is a prevalent error in the previous flows. What if the ledger automatically delivered the notification to the receiver over the reliable channel that the Internet Computer provides? That is the idea behind the transfer-notify flow.
There is one issue we need to sort out, however.
When we relied on the webpage to send the notification, we could include the Invoice ID into the payload, making it possible for the shop to identify the relevant order.
If we ask the ledger to send the payment notification, we must pass the Invoice ID in that message.
The common way to address this issue is to add the memo
argument to the transfer arguments, allowing the caller to attach an arbitrary payload to the transaction details.
service : {
// Transfers token amount from the account of the (implicit) caller
// to the account specified by the principal.
// If the transfer is successful, sends a notification to the receiver.
// Arguments:
// amount - the token amount to transfer.
// to - the receiver of the tokens.
// memo - an opaque identifier attached to the notification.
transfer_notify(record {
amount : nat;
to : principal;
memo : opt blob;
}) -> (TxReceipt);
}
The flow proceeds as follows:
- In the negotiation phase, the webpage displays the payment details and starts polling the shop for payment confirmation.
- In the payment phase, I use my wallet to execute the
transfer_notify({to = Shop, amount = Price, memo = InvoiceID})
call on the ledger. -
Once the transfer succeeds, the ledger notifies the shop about the payment, providing the amount and the
memo
containing the Invoice ID. The shop consumes the notification and changes the order status. The next time the webpage polls the shop, the shop replies with a confirmation, and I see a positive message.
Let us check how this flow compares to the previous ones:
- The ledger must process at least two messages: a transfer from the owner and a notification to the shop.
- The ledger charges a combined fee for my transfer and notification. Whether the ledger charges two fees or gives a discount depends on the implementation. Let us assume that the ledger charges 1½ fees.
- The ledger needs to hold a notification in memory until the flow completes. The notification must contain at least the payer principal, the memo (up to 32 bytes), and the amount, which amounts to at least 70 bytes per flow.
- The flow support unlimited concurrency.
- The notification feature adds a lot of complexity to the ledger implementation. The ledger might need to deal with unresponsive destinations and implement a retry policy for delivering notifications.
- The ledger sends the notification on-chain, making it very likely that the shop will receive the notification. Still, there is a possibility that the notification will not get through if the destination is overloaded.
Transfer-fetch
The transfer-fetch flow relies on the ability to request details of past transactions from the ledger. After I transfer tokens to the shop, specifying the Invoice ID as the transaction memo, the ledger issues a unique transaction identifier. I can then pass this identifier to the shop as proof of my payment. The shop can fetch transaction details directly from the ledger to validate the payment. Below is the interface we expect from the ledger.
service : {
// Transfers token amount from the account of the (implicit) caller
// to the account specified by the principal.
// Returns a unique transaction identifier.
// Arguments:
// amount - the token amount to transfer.
// to - the receiver of the tokens.
// memo - an opaque identifier attached to the transaction.
transfer(record {
amount : nat;
to : principal;
memo : opt blob;
}) -> (nat);
// Retrieves details of the transaction with the specified identifier.
// Arguments:
// txid - a unique transaction identifier.
fetch(txid : nat) -> (opt record {
from : principal;
to : principal;
amount : nat;
memo : opt blob;
});
}
The flow proceeds as follows:
- In the negotiation phase, the webpage displays the payment details, a text field for the transaction identifier, and a big green
Done
button. -
In the payment phase, I use my wallet to execute the
transfer({to = Shop, amount = Price, memo = InvoiceID})
call on the ledger. If the transfer is successful, the transaction receipt contains a unique transaction identifier. - I paste the transaction identifier into the text field and press the green button. Once the shop receives the notification with the transaction identifier, it fetches the transaction from the ledger and validates the amount and the memo. If the validation passes, the order is complete.
- The ledger must process at least two messages: a
transfer
from me and afetch
request from the shop. - The ledger charges one fee for my transfer. The transaction details are usually publicly available and require no access fee.
- The ledger does not need to store any additional information for the payment flow.
- The flow support unlimited concurrency.
- Transaction access interfaces are handy and ubiquitous. Little additional complexity is usually required to enable the flow.
- The failure cases are very similar to the invoice-account flow, except that there is no easy way to monitor outstanding invoices. One possible recovery is constructing an index of all the ledger transactions and scanning for transfers matching the open orders.
Conclusion
We analyzed several payment flows for ledgers hosted on the Internet Computer. All the flows we discussed had three phases: negotiation, payment, and notification.
Below is a table comparing the payment flows.
invoice account | approve-transfer-from | transfer-notify | transfer-fetch | |
---|---|---|---|---|
Ledger messages | 2 | 2 | 2 | 2 |
Fees | 2 | 2 | 1½ | 1 |
Ledger memory (bytes) | 70 | 68 | 70 | 0 |
Concurrent payments | ✔ | ✗ | ✔ | ✔ |
Recurrent payments | ✗ | ✔ | ✗ | ✗ |
Ledger complexity | simple | moderate | complex | simple |
Failure recovery | not easy | ok | hard but rare | hard |
So which flow is the best one? None of them is a clear winner on all fronts. You might prefer different flows based on your design goals and the application needs.