Payments - Invoice Canister Design Review

Payments - Invoice Canister

As we look to refine the developer experience around payments, we concluded that in some instances the ledger canister interface may be too “low level”. For example, a canister that would like to access/implement a payment system would need to implement from scratch things like protection against double spending against the ledger interface. For that reason, we propose to design an interface that will make it easier for a typical canister to add payment functionality.

Goals

Goals for this project are as follows:

  1. Solution should be simple to include and develop against locally
  2. Canister can easily check its balance
  3. Canister can verify that a payment has been satisfied
  4. User can submit payment from a wallet
  5. Design should be compatible with BTC, ETH, and SNS ledgers as they become available

Non-goals

  • We do not intend to change the ICP ledger
  • This interface won’t specifically handle minting cycles or other secondary ledger features
  • Handling escrow payments
  • Automating recurring payments

Open Questions

  • Should this be a new canister type in dfx, a single centralized canister on the NNS subnet, or both?

The Interface

// interface.did
type Token = variant {
	ICP: Text;
	// More to come
};

type AccountIdentifier = variant { 
	Text;
	Principal;
},

type PrivateInfo = {
	// human-readable description of the transaction
	description: Text;
	meta: Blob;
};

type Invoice = record {
	id: Hash; // uuid
	creator: Principal;
	privateInfo: PrivateInfo;
	amount: Nat;
	amount_transferred: Nat;
	token: Token;
	verifiedAtTime: opt Time;
	paid: Bool;
	refunded: Bool;
	expiration: Time;
	destination: AccountIdentifier;
	refundAccount: opt AccountIdentifier;
};

type InvoiceCreateArgs = record {
	amount: Nat;
    token: Token;
	destination: opt AccountIdentifier;
	privateInfo: opt PrivateInfo;
	refundAccount: opt AccountIdentifier;
};

type TransferArgs = record {
    amount: Nat;
    token: Token;
	destination: AccountIdentifier;
	source: opt AccountIdentifier;
};

type BalanceArgs = record {
	token: Token;
};

service {
	create_invoice(InvoiceCreateArgs) -> (Invoice);
	refund_invoice(Hash) -> (Result);
	get_invoice(Hash) -> (Invoice) query;
	get_balance(BalanceArgs) -> Nat;
	transfer(TransferArgs) -> (Result);
	validate_payment(Hash) -> Status;
};

Design Choices

The goal here was to design a flow where a client application such as a webpage, could initiate a payment flow that could be used to gate services or transfer ownership of assets.

The Invoice Canister will consolidate payments into a single balance per token, which will be the location that you can then transfer from and check your balance. The implementation may differ slightly for Bitcoin versus ICP, but the Invoice Canister will handle the implementation and abstract those differences into a single API.

Basic Payment Flow ( hypothetical )

A canister smart contract can receive a request to purchase, create an invoice, and store the Principal of the caller and the UUID of the invoice.

Once the payment has been satisfied, the canister can check the status of the payment with validate_payment, while the Invoice canister checks the ledger. The canister can then present the status to the client, and satisfy the payment flow.

Here is an example flow diagram:

27 Likes

Looks good! I’ll dive in with a deeper review when I get a chance.

Please consider putting in Namespaced Interfaces: Proposal to Adopt the Namespaced Interfaces Pattern as a Best Practice for IC Developers

3 Likes

Are transfer and get balance just standard ledger functions or are they specific to invoices?

3 Likes

It’s an abstraction across multiple ledgers. You can create an invoice for any token supported by the Invoice Canister, and invoice_transfer and get_balance would use the default AccountIdentifier for that token.

It may vary across ledgers, but for ICP for example, it will consolidate your balance to a single subaccount, conceptually like a “bank account” abstraction

4 Likes

The Hash type is not defined. Is it blob? (I guess it’s more pseudo-code at this moment yet, the syntax is a bit off to be parsed by Candid.)

it is pseuodocode for now. We haven’t defined how we want to do the UUID implementation yet, and I wrote the interface freehand, so I’m not surprised if there are a couple typos

This is very awesome! What is the anticipated release date for this invoice canister system? I would love to use it asap.

We’re targeting Q1 2022

4 Likes

Thanks, @kpeacock, great to see progress on this front so quickly!

Some first thoughts on the interface:

type Token = variant {

We should probably make token type just a text (maybe something like type Token = record { symbol : text }; for extra type safety). Adding more constructors to a variant in return position is not a backward compatible change, and we probably want to support more tokens in future.

type AccountIdentifier = variant {
Text;
Principal;
},

We should probably also support ICP ledger AccountIdentifier here which is a blob.

type BalanceArgs = record {
token: Token;
};

We probably need an account identifier there as well.

PrivateInfo

Is it called “private” because the service doesn’t care about this data? I think that naming might be a bit confusing.

get_invoice(Hash) → (Invoice) query;
get_balance(BalanceArgs) → Nat;
validate_payment(Hash) → Status;

It seems somewhat inconsistent that get_invoice is a query but the other two methods aren’t. Since we don’t have inter-canister queries yet, I suggest we make get_invoice and validate_payments update calls so that they can talk to other canisters for scalability reasons (you might want to distribute Hash → Invoice hash table across many canisters). If get_balance will need to talk to another canister (e.g., ICP ledger), it also has to be an update call.

transfer(TransferArgs) → (Result);

Should we have a variant of transfer that accepts an invoice? Otherwise how do you move invoice to paid state? Will the service sync the corresponding block chain and look at the blocks searching for invoice confirmations?

UUID

Just curious, what’s the reasoning behind using UUIDs and not, say, SHA-256 hash of the immutable fields of Invoice record?

I think we should also specify how fees fit into that model, the current interface doesn’t mention fees at all.

Also, I’m not sure I understand how transfer is going to be implemented. For example, one cannot simply put an intermediary canister between a client and the ICP ledger because ICP ledger uses the identity of the caller to validate payments, and an intermediary would not be able to preserve the caller (unless we instruct the Ledger to trust the intermediary).

3 Likes

Some initial thoughts:

We should probably also support ICP ledger AccountIdentifier here which is a blob.

That makes sense

PrivateInfo

The purpose here is to have the ability to store information that only the canister responsible for creating the invoice can access. The basic information will be public, but the description and metadata are private.

It seems somewhat inconsistent that get_invoice is a query

We discussed whether the various calls should be queries, and decided that the Invoice data was likely to be contained on a single Invoice Canister. It could conceivably scale to a point where the data can’t be contained on a single canister though, at which point we couldn’t maintain the query, so fair point.

Should we have a variant of transfer that accepts an invoice?

Transfer is meant to skip the invoice functionality. If the Invoice Canister is maintaining a balance of a given token for your canister, you can choose to send it whenever you want.

On the ICP Ledger, the Invoice Canister would be maintaining a balance for you on a subaccount that it controls, which is how it will be able to make calls to the ledger on your behalf. transfer is how you can move the funds off to a personal wallet, or to a subaccount controlled by your own canister, if you implement ledger functionality directly

Just curious, what’s the reasoning behind using UUIDs and not, say, SHA-256 hash of the immutable fields of Invoice record?

Our goal is simply to have a unique ID for each invoice with no collisions. A SHA-256 hash may be totally acceptable, given the verifiedAtTime and expiration fields

2 Likes

A few questions:

  • Will this design support an end user authorizing a third party to make a payment on their behalf? The canonical use case is a DEX. Most of the proposed IC token standards use an ERC-20-like approve / transferFrom flow, but IIUC your current design doesn’t support that. (In fact, I’m not sure an invoice-style payment scheme is even compatible with third-party payments.)

  • Since the invoice canister is essentially a wrapper around various token canisters, this design relies heavily on inter-canister calls. But inter-canister queries are still not supported, which will make simple balance queries take seconds to complete (as is reflected in the fact that get_balance is an update in your interface). Will this mean that an inter-canister query implementation gets prioritized in the coming few months? (That would be awesome…)

  • The ICP ledger canister uses certified variables to enhance query security. Community-created token standards may follow suit. Can and how will those security guarantees transfer over to invoice canisters?


  • Should this be a new canister type in dfx , a single centralized canister on the NNS subnet, or both?

I think that depends on whether every dapp should have their own invoice canister, or instead share a common invoice canister? Another idea is to make this an open internet service on the SNS? Just throwing out ideas.

This could be used in conjunction with third party payments, but it wouldn’t enable them, necessarily. The invoice could easily be the destination of a payment from a DEX, and the patterns I’m using would hopefully be useful to someone building a DEX as a reference.

As for queries, we could probably certify queries against the invoices with some extra work. I wouldn’t commit to proper queries anywhere else though, because the priority for this project is to abstract multiple types of ledgers whose interfaces haven’t been fully established yet, such as BTC and ETH.

Will this mean that an inter-canister query implementation gets prioritized in the coming few months

Probably not. Queries within subnets are possible, but there’s no movement right now on cross-subnet queries

I’m leaning toward releasing this as an MVP that people can self-deploy and offer feedback on for a while first, and then we can possibly set it up as a central service with an SNS down the road

1 Like

I’m ready to self deploy and test :slightly_smiling_face:

شكرا لك صديقي على الجهد المبذول في كتابة المنشور
:+1:

Should be coming soon! I’ve got the core built out, and I need to get the whole suite end-to-end tested

6 Likes

Update - I’ve successfully tested the core payment flow for ICP locally. There’s a bunch of work to clean up the repo and harden it with testing, but it’s coming along!

Here’s the latest, closer-to-finalized interface.

type VerifyInvoiceSuccess = 
 variant {
   AlreadyVerified: record {invoice: Invoice;};
   Paid: record {invoice: Invoice;};
 };
type VerifyInvoiceResult = 
 variant {
   Err: VerifyInvoiceErr;
   Ok: VerifyInvoiceSuccess;
 };
type VerifyInvoiceErr = 
 record {
   kind:
    variant {
      Expired;
      InvalidInvoiceId;
      NotFound;
      NotYetPaid;
      TransferError;
    };
   message: opt text;
 };
type VerifyInvoiceArgs = record {id: nat;};
type TransferArgs = 
 record {
   amount: float64;
   destination: AccountIdentifier;
   source: opt AccountIdentifier;
   token: Token;
 };
type TokenVerbose = 
 record {
   decimals: int;
   meta: opt record {Issuer: text;};
   symbol: text;
 };
type Token = record {symbol: text;};
type Time = int;
type Invoice = 
 record {
   amount: nat;
   amountPaid: nat;
   creator: principal;
   destination: AccountIdentifier;
   details: opt Details;
   expiration: Time;
   id: nat;
   paid: bool;
   refundAccount: opt AccountIdentifier;
   refunded: bool;
   token: TokenVerbose;
   verifiedAtTime: opt Time;
 };
type GetInvoiceSuccess = record {invoice: Invoice;};
type GetInvoiceResult = 
 variant {
   Err: GetInvoiceErr;
   Ok: GetInvoiceSuccess;
 };
type GetInvoiceErr = 
 record {
   kind: variant {
           InvalidInvoiceId;
           NotFound;
         };
   message: opt text;
 };
type GetInvoiceArgs = record {id: nat;};
type GetCallerIdentifierSuccess = record {
                                    accountIdentifier: AccountIdentifier;};
type GetCallerIdentifierResult = 
 variant {
   Err: GetCallerIdentifierErr;
   Ok: GetCallerIdentifierSuccess;
 };
type GetCallerIdentifierErr = 
 record {
   kind: variant {InvalidToken;};
   message: opt text;
 };
type GetCallerIdentifierArgs = record {token: Token;};
type GetBalanceSuccess = record {balance: nat;};
type GetBalanceResult = 
 variant {
   Err: GetBalanceErr;
   Ok: GetBalanceSuccess;
 };
type GetBalanceErr = 
 record {
   kind: variant {
           InvalidToken;
           NotFound;
         };
   message: opt text;
 };
type GetBalanceArgs = record {token: Token;};
type Details = 
 record {
   description: text;
   meta: blob;
 };
type CreateInvoiceSuccess = record {invoice: Invoice;};
type CreateInvoiceResult = 
 variant {
   Err: CreateInvoiceErr;
   Ok: CreateInvoiceSuccess;
 };
type CreateInvoiceErr = 
 record {
   kind:
    variant {
      InvalidAmount;
      InvalidDestination;
      InvalidDetails;
      InvalidRefundAccount;
      InvalidToken;
    };
   message: opt text;
 };
type CreateInvoiceArgs = 
 record {
   amount: nat;
   details: opt Details;
   refundAccount: opt AccountIdentifier;
   token: Token;
 };
type AccountIdentifier = 
 variant {
   "blob": blob;
   "principal": principal;
   "text": text;
 };
service : {
  accountIdentifierToBlob: (AccountIdentifier) -> (blob);
  create_invoice: (CreateInvoiceArgs) -> (CreateInvoiceResult);
  get_balance: (GetBalanceArgs) -> (GetBalanceResult);
  get_caller_identifier: (GetCallerIdentifierArgs) ->
   (GetCallerIdentifierResult) query;
  get_invoice: (GetInvoiceArgs) -> (GetInvoiceResult);
  refund_invoice: () -> () oneway;
  remaining_cycles: () -> (nat) query;
  transfer: (TransferArgs) -> ();
  verify_invoice: (VerifyInvoiceArgs) -> (VerifyInvoiceResult);
}
4 Likes

I’m curious if there’s a reason you’re doing things like:

type VerifyInvoiceResult = 
 variant {
   Err: VerifyInvoiceErr;
   Ok: VerifyInvoiceSuccess;
 };

Instead of:

type VerifyInvoiceResult = Result<VerifyInvoiceSuccess, VerifyInvoiceErr>
4 Likes

I’m doing it because the Result type uses lowercase Ok and Err, while the Rust Result uses uppercase, and I wanted to try to make it consistent. None of the utility methods of Result are particularly useful to me, so I figured I wasn’t missing out on much.

Also, I’m protected from any upgrades to the Result base type that could accidentally break compatibility during an upgrade

3 Likes

Hi kpeacock, how I payment ICP by python?

This is a community library for a Python IC agent GitHub - rocklabs-io/ic-py: Python Agent Library for the DFINITY Internet Computer. You’ll be able to call the Invoice canister to get an accountidentifier that will serve as a simple custodial account to receive and make payments

1 Like