CAP: 0023
Title: Two-Part Payments with BalanceEntry
Author: Jonathan Jove
Status: Draft
Created: 2019-06-04
Discussion: https://github.com/stellar/stellar-protocol/issues/303
Protocol version: TBD
Payments can fail depending on the state of the destination account. This proposal introduces new operations that separate sending a payment from receiving the payment. Then the success of sending depends only on the state of the sending account and success of receiving depends only on the state of the receiving account.
This proposal seeks to solve the following problem: it should be easy to send a payment to an account that is not necessarily prepared to receive the payment. There are several manifestations of this problem, the two most important being
- it should be easy for protocols (like an implementation of payment channels) to pay out to participants, and
- it should be easy for issuers to issue assets non-interactively.
This proposal is aligned with the following Stellar Network Goal: The Stellar Network should enable cross-border payments, i.e. payments via exchange of assets, throughout the globe, enabling users to make payments between assets in a manner that is fast, cheap, and highly usable.
We introduce BalanceEntry
as a new type of LedgerEntry
which represents the
transfer of ownership of some amount of an asset. CreateBalance
and
ClaimBalance
operation enable the temporal separation of initiating and
receiving a payment, respectively. This will facilitate protocols built on the
Stellar network by providing a mechanism for payments that never fail. Existing
proposals, such as those for deterministic accounts, can provide a similar
mechanism but are not able to handle authorization restricted assets as easily.
A specific and simple protocol that will be facilitated is the asset issuance
protocol that creates an account for another party and issues an asset to that
account.
We first introduce BalanceEntry
and the corresponding changes for
LedgerEntryType
and LedgerEntry
.
enum LedgerEntryType
{
// ... ACCOUNT, TRUSTLINE, OFFER, unchanged ...
DATA = 3,
BALANCE = 4
};
struct BalanceEntry
{
// If this BalanceEntry was created by operation index I in a transaction
// with sourceAccount S and seqNum N, then balanceID = SHA256(S, N, I)
Hash balanceID;
// Can only be revoked by createdBy
AccountID createdBy;
// Can only be claimed by claimableBy
AccountID claimableBy;
// Amount of native asset to pay the reserve as of the ledger when this
// BalanceEntry was created
int64 reserve;
// Any asset including native
Asset asset;
// Amount of asset
int64 amount;
// If authorize is true, authorize claimableBy for asset when this
// BalanceEntry is claimed
bool authorize;
// Cannot be revoked (eg. claimed by createdBy) until after revokeTime
Timepoint* revokeTime;
};
struct LedgerEntry
{
uint32 lastModifiedLedgerSeq; // ledger the LedgerEntry was last changed
union switch (LedgerEntryType type)
{
// ... ACCOUNT, TRUSTLINE, OFFER, DATA unchanged ...
case BALANCE:
BalanceEntry balance;
}
data;
// reserved for future use
union switch (int v)
{
case 0:
void;
}
ext;
};
We next introduce the new operations CreateBalance
, ClaimBalance
, and
AuthorizeBalance
and the corresponding changes to OperationType
and
Operation
.
enum OperationType
{
// ... CREATE_ACCOUNT, ..., BUMP_SEQUENCE unchanged ...
MANAGE_BUY_OFFER = 12,
CREATE_BALANCE = 13,
CLAIM_BALANCE = 14,
AUTHORIZE_BALANCE = 15
};
struct CreateBalance
{
AccountID claimableBy;
Asset asset;
int64 amount;
Timepoint* revokeTime;
};
struct ClaimBalance
{
Hash balanceID;
};
struct AuthorizeBalance
{
Hash balanceID;
bool authorize;
};
struct Operation
{
// sourceAccount is the account used to run the operation
// if not set, the runtime defaults to "sourceAccount" specified at
// the transaction level
AccountID* sourceAccount;
union switch (OperationType type)
{
// ... CREATE_ACOUNT, ..., BUMP_SEQUENCE unchanged ...
case CREATE_BALANCE:
CreateBalance createBalanceOp;
case CLAIM_BALANCE:
ClaimBalance claimBalanceOp;
case AUTHORIZE_BALANCE:
AuthorizeBalance authorizeBalanceOp;
}
body;
};
We now introduce the result types CreateBalanceResult
, ClaimBalanceResult
,
and AuthorizeBalanceResult
and the corresponding changes to OperationResult
.
enum CreateBalanceResultCode
{
CREATE_BALANCE_SUCCESS = 0,
CREATE_BALANCE_MALFORMED = -1,
CREATE_BALANCE_NOT_AUTHORIZED = -2,
CREATE_BALANCE_LOW_RESERVE = -3,
CREATE_BALANCE_UNDERFUNDED = -4
};
union CreateBalanceResult switch (CreateBalanceResultCode code)
{
case CREATE_BALANCE_SUCCESS:
void;
default:
void;
};
enum ClaimBalanceResultCode
{
CLAIM_BALANCE_SUCCESS = 0,
CLAIM_BALANCE_DOES_NOT_EXIST = -1,
CLAIM_BALANCE_CANNOT_CLAIM = -2
CLAIM_BALANCE_LOW_RESERVE = -3,
CLAIM_BALANCE_NOT_AUTHORIZED = -4,
CLAIM_BALANCE_LINE_FULL = -5,
};
union ClaimBalanceResult switch (ClaimBalanceResultCode code)
{
case CLAIM_BALANCE_SUCCESS:
void;
default:
void;
};
enum AuthorizeBalanceResultCode
{
AUTHORIZE_BALANCE_SUCCESS = 0,
AUTHORIZE_BALANCE_DOES_NOT_EXIST = -1,
AUTHORIZE_BALANCE_NOT_ISSUER = -2,
AUTHORIZE_BALANCE_TRUST_NOT_REQUIRED = -3,
AUTHORIZE_BALANCE_CANT_REVOKE = -4
};
union AuthorizeBalanceResult switch (AuthorizeBalanceResultCode code)
{
case AUTHORIZE_BALANCE_SUCCESS:
void;
default:
void;
};
union OperationResult switch (OperationResultCode code)
{
case opINNER:
union switch (OperationType type)
{
// ... CREATE_ACOUNT, ..., BUMP_SEQUENCE unchanged ...
case CREATE_BALANCE_ENTRY:
CreateBalanceResult createBalanceResult;
case CLAIM_BALANCE_ENTRY:
ClaimBalanceResult claimBalanceResult;
case AUTHORIZE_BALANCE_ENTRY:
AuthorizeBalanceResult authorizeBalanceResult;
}
tr;
default:
void;
};
A BalanceEntry
can only be created by the CreateBalance
operation. The
balanceID
is generated from the SHA256 hash of the concatenation of the
source account of the transaction in which the BalanceEntry
was created, the
sequence number of the source account, and the index of the CreateBalance
operation that created the BalanceEntry
. This tuple ensures, up to hash
collision, that the balanceID
is unique. The createdBy
is set to the source
account of the CreateBalance
operation that created the BalanceEntry
. The
reserve
equals the baseReserve
at the time that the BalanceEntry
is
created. Every BalanceEntry
is created with authorize = FALSE
, and the
authorize
field is the only mutable part of a BalanceEntry
. All other
fields are as specified in the CreateBalance
operation.
CreateBalance
returns
CREATE_BALANCE_MALFORMED
if the asset is invalid or the amount is non-positiveCREATE_BALANCE_NOT_AUTHORIZED
if the asset isAUTH_REQUIRED
and the source account does not have an authorized trust lineCREATE_BALANCE_LOW_RESERVE
if the source account has insufficient native asset available (accounting for reserve and liabilities) to sendbaseReserve
CREATE_BALANCE_UNDERFUNDED
if the source account has insufficientasset
available (accounting for liabilities) to sendamount
CREATE_BALANCE_SUCCESS
otherwise
A BalanceEntry
can only be deleted by the ClaimBalance
operation. The source
account of the ClaimBalance
operation must match claimableBy
if closeTime
is before revokeTime
, or must match createdBy
otherwise. If the BalanceEntry
is claimed by claimableBy
and claimableBy
does not have a trust line for the
asset
, then ClaimBalance
will create one with limit
equal to amount
. If
the BalanceEntry
is claimed by claimableBy
and authorize = TRUE
, then
ClaimBalance
will authorize the trust line (regardless of whether it was just
created).
ClaimBalance
returns
CLAIM_BALANCE_DOES_NOT_EXIST
if there is noBalanceEntry
matchingbalanceID
CLAIM_BALANCE_CANNOT_CLAIM
if the source account does not match the the relevant ofcreatedBy
orclaimableBy
, depending on thecloseTime
andrevokeTime
CLAIM_BALANCE_LOW_RESERVE
if the source account does not have a trust line forasset
, and it does not have sufficient balance (accounting for reserve and liabilities) to create a trust line even after receiving thereserve
from theBalanceEntry
CLAIM_BALANCE_NOT_AUTHORIZED
if the source account does not have an authorized trust line (checked afterClaimBalance
potentially authorizes the trust line)CLAIM_BALANCE_LINE_FULL
if the source account does not have sufficient available balance of native asset (accounting for liabilities) to receivereserve
, or does not have sufficient available balance ofasset
(accounting for liabilities) to receiveamount
CLAIM_BALANCE_SUCCESS
otherwise
The only part of a BalanceEntry
that is mutable is the authorize
flag, which
can be changed by the issuer of asset
using AuthorizeBalance
.
AuthorizeBalance
returns
AUTHORIZE_BALANCE_DOES_NOT_EXIST
if there is noBalanceEntry
matchingbalanceID
AUTHORIZE_BALANCE_NOT_ISSUER
if the source account is not the issuer ofasset
AUTHORIZE_BALANCE_TRUST_NOT_REQUIRED
theasset
is notAUTH_REQUIRED
AUTHORIZE_BALANCE_CANT_REVOKE
the asset isAUTH_IMMUTABLE
AUTHORIZE_BALANCE_SUCCESS
otherwise
Each BalanceEntry
exists as an independent entity on the ledger. It is clear
that the BalanceEntry
cannot be a sub-entry of claimableBy
because it is a
security risk for accounts to be able to add sub-entries to other accounts. But
why should the BalanceEntry
be an independent entity on the ledger rather than
a sub-entry of createdBy
? There are three main benefits of this design:
- Sending accounts are not limited in the number of payments they can initiate
- Sending accounts can be merged even if they initiated payments that have not been claimed
- Receiving accounts always receive the reserve from the claimed
BalanceEntry
The third benefit facilitates a desirable optimization in which a BalanceEntry
can be directly converted into a TrustLine
. This is useful in some asset
issuance workflows where the issuer wants to pay for the trustline but does not
want the recipient to spend the funds prior to creating the trustline. Of course
if the base reserve were raised after the BalanceEntry
was created then the
reserve received would not be sufficient to pay for the trustline, but they would
at least still receive the reserve. But if the BalanceEntry
were a sub-entry
of the sending account and the base reserve were raised such that the sending
account no longer satisfies the reserve requirement then the receiving account
would receive no reserve (because accounts that do not satisfy the reserve
requirement cannot have their native balance decrease). While this is an unlikely
edge case, it does simplify the behavior of BalanceEntry
by guaranteeing that
the receiving account always receives both the reserve and the amount of asset.
Each BalanceEntry
can only be claimed by claimableBy
or createdBy
(exactly
one of them depending on the ledger close time). As a consequence, it is possible
for a BalanceEntry
to become stranded. If claimableBy
is merged and the
private key is not known, then the BalanceEntry
will never be claimable by the
intended recipient. Similarly if createdBy
is merged and the private key is not
known, then the BalanceEntry
will never be reclaimable by the sender.
Suppose that we try to relax this requirement in order to avoid this downside. We
could instead attach two public keys to the BalanceEntry
, where the sender alone
knows one of the private keys and the recipient alone knows the other. Which key is
currently usable would be determined by the ledger close time. The operation to
claim the BalanceEntry
could then specify an account A and a signature over A
from the key that is currently usable. This would allow the appropriate party to
claim the BalanceEntry
into any account that they control. But this would also
make it considerably easier to circumvent authorization restrictions on assets.
For instance, an authorized account could create a BalanceEntry
with a recipient
public key whose private key is known only to some other party. That party would
then control the funds in the BalanceEntry
and could claim them into any account
that is authorized. A similar scheme could be executed today by changing the
signers on an account, but this would only be possible once per authorized account
and cannot separate out a fraction of the funds. In summary, an approach that
could allow BalanceEntry
to be claimable into any account would significantly
weaken the strength of authorization restrictions.
When issuing an authorization required asset to an account that does not exist,
there is a chicken-and-egg problem. The account does not exist and as such does
not have a trustline for the asset, so the issuer cannot authorize it. But the
issuer cannot send the funds without the existence of an authorized trustline.
This results in additional communication, either off-chain or on-chain. This
proposal seeks to simplify the situation. When a BalanceEntry
is claimed by
claimableBy
, a trustline for the asset will be implicitly created if one does
not exist. If the authorize
flag is also set, then the trustline will also be
authorized regardless of whether it was implicitly created. Because the flag
can only be set by the issuer and the BalanceEntry
can only be claimed by
claimableBy
, this allows the issuer to effectively "pre-authorize". But if
the issuer is using this functionality, then the issuer must be cognizant about
clearing the authorize
flag for any BalanceEntry
paid to account A if they
revoke authorization from A.
All downstream systems will need updated XDR in order to recognize the new operations and ledger entries.
This proposal will slightly reduce the effectiveness of base reserve changes,
because a BalanceEntry
that has insufficient reserve is still usable.
None yet.
None yet.