CAP: 0067
Title: Unified Asset Events
Working Group:
Owner: Siddharth Suresh <@sisuresh>
Authors: Siddharth Suresh <@sisuresh>, Leigh McCulloch <@leighmcculloch>
Consulted: Dmytro Kozhevin <@dmkozh>, Jake Urban <[email protected]>, Simon Chow <[email protected]>
Status: Draft
Created: 2025-01-13
Discussion: https://github.com/stellar/stellar-protocol/discussions/1553
Protocol version: 23
Emit transfer
, mint
, burn
, clawback
, and fee
events in Classic in the same format as what we see in Soroban so that the movement of assets can be tracked using a single stream of data. In addition to emitting events in Classic, update the events emitted in the Stellar Asset Contract to be semantically correct and compatible with SEP-41.
Tracking the movement of Stellar assets today is complex because you need to consume both Soroban events emitted by the Stellar Asset Contract and ledger entry changes for Classic operations. There are also differences between Stellar assets and custom Soroban tokens that this CAP will address so those differences will be made irrelevant to the end user.
This CAP is aligned with the following Stellar Network Goals:
- The Stellar Network should be secure and reliable, and should bias towards safety, simplicity, reliability, and performance over new functionality.
This CAP specifies three changes -
- Emit an event for every movement of an
Asset
of typesASSET_TYPE_NATIVE
,ASSET_TYPE_CREDIT_ALPHANUM4
, andASSET_TYPE_CREDIT_ALPHANUM12
. in Stellar classic. All of the added events will follow the format of the existing Stellar Asset Contract events, with the exception of a newfee
event to track fees paid by the source account. - Remove the admin from the topics of the
mint
andclawback
events emitted in the SAC. - Update issuer semantics in the SAC so that a
transfer
involving the issuer will emit the semantically correct event (mint
orburn
).
This patch of XDR changes is based on the XDR files in commit 734bcccdbb6d1f7e794793ad3b8be51f3ba76f92
of stellar-xdr.
diff --git a/Stellar-contract.x b/Stellar-contract.x
index 5113005..5aced97 100644
--- a/Stellar-contract.x
+++ b/Stellar-contract.x
@@ -179,7 +179,10 @@ case CONTRACT_EXECUTABLE_STELLAR_ASSET:
enum SCAddressType
{
SC_ADDRESS_TYPE_ACCOUNT = 0,
- SC_ADDRESS_TYPE_CONTRACT = 1
+ SC_ADDRESS_TYPE_CONTRACT = 1,
+ SC_ADDRESS_TYPE_CLAIMABLE_BALANCE = 2,
+ SC_ADDRESS_TYPE_LIQUIDITY_POOL = 3,
+ SC_ADDRESS_TYPE_MUXED_ACCOUNT = 4
};
union SCAddress switch (SCAddressType type)
@@ -188,6 +191,12 @@ case SC_ADDRESS_TYPE_ACCOUNT:
AccountID accountId;
case SC_ADDRESS_TYPE_CONTRACT:
Hash contractId;
+case SC_ADDRESS_TYPE_CLAIMABLE_BALANCE:
+ ClaimableBalanceID claimableBalanceId;
+case SC_ADDRESS_TYPE_LIQUIDITY_POOL:
+ PoolID liquidityPoolId;
+case SC_ADDRESS_TYPE_MUXED_ACCOUNT:
+ MuxedAccount accountId;
};
%struct SCVal;
diff --git a/Stellar-ledger.x b/Stellar-ledger.x
index 6ab63fb..79e6bab 100644
--- a/Stellar-ledger.x
+++ b/Stellar-ledger.x
@@ -446,7 +446,44 @@ struct TransactionMetaV3
// Soroban transactions).
};
+struct OperationMetaV2
+{
+ // We can use this to add more fields, or because it
+ // is first, to change OperationMetaV2 into a union.
+ ExtensionPoint ext;
+
+ LedgerEntryChanges changes;
+
+ ContractEvent events<>;
+ DiagnosticEvent diagnosticEvents<>;
+}
+
+struct SorobanTransactionMetaV2
+{
+ SorobanTransactionMetaExt ext;
+
+ SCVal returnValue;
+}
+
+struct TransactionMetaV4
+{
+ ExtensionPoint ext;
+
+ LedgerEntryChanges txChangesBefore; // tx level changes before operations
+ // are applied if any
+ OperationMetaV2 operations<>; // meta for each operation
+ LedgerEntryChanges txChangesAfter; // tx level changes after operations are
+ // applied if any
+ SorobanTransactionMetaV2* sorobanMeta; // Soroban-specific meta (only for
+ // Soroban transactions).
+
+ ContractEvent events<>;
+ DiagnosticEvent txDiagnosticEvents<>; // Used for diagnostic information not tied
+ // to an operation.
+};
+
// This is in Stellar-ledger.x to due to a circular dependency
+// Only used before protocol 23
struct InvokeHostFunctionSuccessPreImage
{
SCVal returnValue;
@@ -465,6 +502,8 @@ case 2:
TransactionMetaV2 v2;
case 3:
TransactionMetaV3 v3;
+case 4:
+ TransactionMetaV4 v4;
};
The mint
event will look like:
contract: asset, topics: ["mint", to:Address, sep0011_asset:String], data: amount:i128
The clawback
event will look like:
contract: asset, topics: ["clawback", from:Address, sep0011_asset:String], data: amount:i128
Emit the semantically correct event for a Stellar Asset Contract transfer
when the issuer is involved
At the moment, if the issuer is the sender in a Stellar Asset Contract transfer
, the asset will be minted. If the issuer is the recipient, the asset will be burned. The event emitted in both scenarios, however, is the transfer
event. This CAP changes that behavior to instead emit the mint
/burn
event.
This section will go over the semantics of how the additional transfer
events are emitted for each operation, as well as the fee
event emitted for the fee paid by the source account. These events will be emitted through the events<>
field in the new OperationMetaV2
. Soroban events will be moved to OperationMetaV2
. The hash of the current soroban events will still exist under INVOKE_HOST_FUNCTION_SUCCESS
as it does today. It's also important to note that nothing is being removed from meta, and in fact, the emission of the events mentioned in this section will be configurable through a flag.
Note that the contract
field for these events corresponds to the Stellar Asset Contract address for the respective asset. The Stellar Asset Contract instance is not required to be deployed for the asset. The events will be published using the reserved contract address regardless of deployment status.
The Address
used on the events specified below will be muxed if the classic operation uses a muxed address. This can be done using the new SC_ADDRESS_TYPE_MUXED_ACCOUNT
SCAddressType
. SC_ADDRESS_TYPE_MUXED_ACCOUNT
will not be used within a InvokeHostFunctionOp
, but this point can be revisited in the future. If this new SCAddressType
is used, the type of the underlying MuxedAccount
must be KEY_TYPE_MUXED_ED25519
to prevent multiple ways of setting a non-muxed account.
For each transaction whose source account pays fees for the execution of a transaction, emit an event in the following format:
contract: native asset, topics: ["fee", from:Address], data: [amount:i128]
Where from is the account paying the fee, either the fee bump fee account or the tx source account.
Emit one of the following events -
For a payment not involving the issuer, or if both the sender and receiver are the issuer:
contract: asset, topics: ["transfer", from:Address, to:Address, sep0011_asset:String], data: amount:i128
When sending from an issuer:
contract: asset, topics: ["mint", to:Address, sep0011_asset:String], data: amount:i128
When sending to an issuer:
contract: asset, topics: ["burn", from:Address, sep0011_asset:String], data: amount:i128
For each movement of the asset created by the path payment, emit one of the following -
contract: assetA, topics: ["transfer", from:Address, to:Address, sep0011_asset:String], data: amount:i128
contract: assetB, topics: ["transfer", from:Address, to:Address, sep0011_asset:String], data: amount:i128
If from
is the issuer on a side of the trade, emit the following instead for that side of the trade:
contract: asset, topics: ["mint", to:Address, sep0011_asset:String], data: amount:i128
If to
is the issuer on a side of the trade, emit the following instead for that side of the trade:
contract: asset, topics: ["burn", from:Address, sep0011_asset:String], data: amount:i128
from
is the account being debited (seller).to
is the account being credited (buyer).
The trades within a path payment are conceptually between the source account and the owner of the offers. Those are the addresses that'll appear on the event pairs specified above. At the end of all the trades, we need to emit one more transfer
(or burn
if the destination is the issuer) event to indicate a transfer from the source account to the destination account. The amount will be equivalent to the sum of the destination asset received on the trades of the final hop.
Note that if the path payment has an empty path and sendAsset == destAsset
, then the operation is effectively a regular payment, so emit an event following the specifications of the payment section.
Emit the following event:
contract: native asset, topics: ["transfer", from:Address, to:Address, sep0011_asset:String], data: amount:i128
from
is the account being debited (creator).to
is the account being credited (created).amount
is the starting native balance.
Emit the following event:
contract: native asset, topics: ["transfer", from:Address, to:Address, sep0011_asset:String], data: amount:i128
from
is the account being debited (merged).to
is the account being credited (merged into).amount
is the merged native balance.
Emit the following event:
contract: asset, topics: ["transfer", from:Address, to:Address, sep0011_asset:String], data: amount:i128
- from is the account being debited.
- to is the claimable balance being created. The type of this address will be
SC_ADDRESS_TYPE_CLAIMABLE_BALANCE
. - amount is the amount moved into the claimable balance.
If an asset is a movement from the issuer of the asset, instead emit for the movement:
contract: asset, topics: ["mint", to:Address, sep0011_asset:String], data: amount:i128
Emit the following event:
contract: asset, topics: ["transfer", from:Address, to:Address, sep0011_asset:String], data: amount:i128
from
is the claimable balance. The type of this address will beSC_ADDRESS_TYPE_CLAIMABLE_BALANCE
.to
is the account being creditedamount
is the amount in the claimable balance
If the claim is a movement to the issuer of the asset, instead emit for the movement:
contract: asset, topics: ["burn", from:Address, sep0011_asset:String], data: amount:i128
Emit the following events:
contract: assetA, topics: ["transfer", from:Address, to:Address, sep0011_asset:String], data: amount:i128
contract: assetB, topics: ["transfer", from:Address, to:Address, sep0011_asset:String], data: amount:i128
from
is the account being debited.to
is the liquidity pool being credited. The type of this address will beSC_ADDRESS_TYPE_LIQUIDITY_POOL
.amount
is the amount moved into the liquidity pool.
If an asset is a movement from the issuer of the asset, instead emit for the movement:
contract: asset, topics: ["mint", to:Address, sep0011_asset:String], data: amount:i128
Emit the following events:
contract: assetA, topics: ["transfer", from:Address, to:Address, sep0011_asset:String], data: amount:i128
contract: assetB, topics: ["transfer", from:Address, to:Address, sep0011_asset:String], data: amount:i128
from
is the liquidity pool. The type of this address will beSC_ADDRESS_TYPE_LIQUIDITY_POOL
.to
is the account being credited.amount
is the amount moved out of the liquidity pool.
If an asset is issued by the withdrawer, instead emit for the movement of the issued asset:
contract: asset, topics: ["burn", from:Address, sep0011_asset:String], data: amount:i128
Emit two events per offer traded against. Each pair of events represents both sides of a trade. This does mean zero events can be emitted if the resulting offer is not marketable -
contract: assetA, topics: ["transfer", from:Address, to:Address, sep0011_asset:String], data: amount:i128
contract: assetB, topics: ["transfer", from:Address, to:Address, sep0011_asset:String], data: amount:i128
If from
is the issuer on a side of the trade, emit the following instead for that side of the trade:
contract: asset, topics: ["mint", to:Address, sep0011_asset:String], data: amount:i128
If to
is the issuer on a side of the trade, emit the following instead for that side of the trade:
contract: asset, topics: ["burn", from:Address, sep0011_asset:String], data: amount:i128
from
is the account being debited (seller).to
is the account being credited (buyer).
Emit the following event:
contract: asset, topics: ["clawback", from:Address, sep0011_asset:String], data: amount:i128
from
is the account or claimable balance being credited.amount
is the amount being moved out of the account and burned.
If either operation is used to revoke authorization from a trustline that deposited into a liquidity pool then claimable balances will be created for the withdrawn assets (See CAP-0038 for more info). If any claimable balances are created due to this scenario, emit the following event:
contract: asset, topics: ["transfer", from:Address, to:Address, sep0011_asset:String], data: amount:i128
from
is the liquidity pool. The type of this address will beSC_ADDRESS_TYPE_LIQUIDITY_POOL
.to
is the claimable balance being created. The type of this address will beSC_ADDRESS_TYPE_CLAIMABLE_BALANCE
.amount
is the amount moved into the claimable balance.
Prior to protocol 8, there was a bug that could result in the minting/burning of XLM. To allow for the ability to build balances with only events, we not only need to emit the events specified in this CAP from genesis, but also handle that bug properly.
For both the mint and burn scenario, the affected account will be the source account, and that should be the only account in an operation with a balance difference not entirely reflected by the events specified in this CAP. If we take the total diff of XLM in an Operations
OperationMeta
(similar to what the ConservationOfLumens invariant does) and emit that diff as a mint/burn event for the source account, then consumers should be able to track balances correctly.
The admin isn't relevant information when a mint or clawback
occurs, and it hinders compatibility with SEP-41 for when these two events are added to it because the admin is an implementation detail. For a custom token, an admin doesn't need to be a single Address
, or an admin may not required at all to emit either event.
This CAP introduces a new TransactionMeta
version, TransactionMetaV4
. Now that we're emitting events for more than just Soroban, this allows us to clean up the structure of meta because TransactionMetaV3
assumed events would only be emitted for Soroban. This change also allows us to emit events at the operation layer instead of the transaction layer using the new OperationMetaV2
type. Transaction level events like fee
will still be emitted at the transaction level under TransactionMetaV4.events
.
It's important to note that transaction meta is not part of the protocol, so the emission of TransactionMetaV4
instead of TransactionMetaV3
can be done using a config flag, allowing consumers of meta to switch on their own time.
Emit the semantically correct event instead of no longer allowing the issuer to transfer due to missing a trustline
The Stellar Asset Contract special cases the issuer logic because issuers can't hold a trustline for their own assets. This matches the logic in Classic. The special case was unnecessary however because the Stellar Asset Contract provides the mint
and burn
functions. This CAP could instead just remove the special case and allow transfers
involving the issuer to fail due to a missing trustline,
but this would break any contracts that rely on this behavior (it's not known at this time if contracts like this exist, but we could check if there are any transfers
on pubnet that involve the issuer). That's why this CAP chooses to instead emit the correct event in this scenario.
For now, the new events will not be hashed into the ledger to give us more flexibility while we figure out if we want to transform more of the meta ledger entry changes into events. We can start hashing the events at a later point.
This CAP adds two new SCAddressType
types - SC_ADDRESS_TYPE_CLAIMABLE_BALANCE
and SC_ADDRESS_TYPE_LIQUIDITY_POOL
. These types are used in the topic of an event where the address is not a contract or a stellar account.
On the protocol upgrade, the SAC will start emitting the mint
and clawback
events without the admin
topic. Also, the transfer
event will not be emitted for transfers
involving the issuer. Instead, the appropriate mint
/burn
will be emitted.
The unified events will not be part of the protocol, so they can be enabled with a configuration flag at anytime.
The additional events will use more resources if a node chooses to emit them.