Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

TokenTransferProcessor and events - Data modeling compliant with CAP-67 unified events #5580

Closed
karthikiyer56 opened this issue Jan 24, 2025 · 33 comments

Comments

@karthikiyer56
Copy link
Contributor

karthikiyer56 commented Jan 24, 2025

Issue #5573 talks about building a generic token transfer processor that hat parses the LedgerCloseMeta to generate a list of TokenTransferEvent events that detail how balances have been updated for an entity (account or smart contract)

The event representation/modeling in #5573, while detailed and descriptive, suffered from the following issues:

  • It was too detailed.
    for e.g, it adds meta information about an operation if the transfer event was caused by an an operation. See example TokenTransferDetails field for ManageOfferBuy operation in #5573
    A more concise way to represent an event would be to record the fact that asset movement has happened as a part of an operation by including operationIndex in the event. Downstream users of this library can use the combination of LedgerSequence, txHash and operationIndex with the transaction to get more detals about the operation and operationResult.

  • It was very prescriptive in terms of what it defines as a transfer event.
    For e.g, In the previous modeling proposal, a simple payment operation from AccountX to AccountY would have resulted in 2 events being generated - a credit event for AccountX , and a debit event for AccountY.
    Moreover, both these events would have the same SimplePaymentDetails struct included, leading to duplication.
    We should not be making any assumptions on what the downstream invokers of the processor library might choose to do with the TokenTransferEvent, and therefore, it is better to represent a transfer of asset as {from: X, to: Y, amount: 123.4, asset: <someAsset> }

  • The biggest issue with the data modeling in #5573 was that it was not complaint with CAP-67 - the unified classic events model as defined by Stellar-Core.
    This has the potential to cause maintenance problems as more and more events are added in the future by the core team.
    It does not make sense for a downstream processor library to have a data model different from how the core team defines it.

To remedy the issues above, this ticket defines a data model that is more aligned with CAP-67, so as to reduce operational/maintenance work for future evolutions


Address types in events

Currently the Address field, as it appears in events emiited by core, can be either:

  1. Account Address
  2. Smart Contract Address.

The Address will be be extended to include ClaimableBalanceIds and LiquidityPoolHashes as well, once CAP-67 is implemented.
Henceforth, Address can mean any of the 4 entities -

  1. Account Address
  2. SmartContract Address
  3. LiquidityPoolHash
  4. ClaimableBalanceId

Events emitted by core

Based on CAP-46-6, there are 4 events currently emitted by core for SAC - Transfer, Mint, Burn, Clawback

** IMPORTANT NOTE **
Currently these events are emitted only for SAC. The purpose of CAP-67 is to generate the same types (and potentially newer events) for classic operations as well
Additionally, As a part of CAP-67, a new Fee event will be added

1. Transfer Event --- existing event for SAC - to be extended to include classic operations in CAP-67

{from: Address, to: Address, sep0011_asset: String, amount: i128} - This event indicates transfer of asset from a source Address to a destination Address.

2. Mint Event --- existing event for SAC - to be extended to include classic operations in CAP-67

{to: Address, sep0011_asset: String, amount: i128} - This represents a mint operation by the issuer account to a recipient

3. Burn Event --- existing event for SAC - to be extended to include classic operations in CAP-67

{from: Address, admin: Address sep0011_asset: String, amount: i128} - This represents a burn operation where an asset is sent from an address back to the issuer, effectively removing it from circulation
** Note: As a part of CAP-67, the admin field will be removed from burn event

4. Clawback Event --- existing event for SAC - to be extended to include classic operations in CAP-67

{from: Address, admin: Address, sep0011_asset: String, amount: i128} - This represents a clawback operation issued by the owner (Admin) to claim back and burn money from an address.
** Note: As a part of CAP-67, the admin field will be removed from clawback event

5. Fee Event --- new event to be included in CAP-67

{from: Address, amount: i128} - This represents the transaction fee (in XLM) paid by the source account for each transaction.


Basic Event Model

It makes sense to categorize events at the top level based on the 5 types of money movement - Transfer, Burn, Mint, Clawback, Fee , instead of based on reason - Fee, SmartContract, ClassicOperation as described in #5573

Downstream consumers can filter by this eventType field in the event structure, when required

Common Fields to all Events

All events will, at the very minimum, have the following fields, irrespective of type.
These fields can be clubbed in the category of EventMeta

  • ledgerSequence - The ledger sequence number in which this event occured
  • closedAt - closing time of the ledger. This field is to associate a temporal quality with the event. For e.g, upstream clients might want to get events between 2 timestamps
  • txHash - The hash of the transaction in which the event happened
  • operationIndex - if the event was generated in response to an operation, this field will indicate the operation index within the transaction (zero-indexed). Otherwise this field will be absent/null (for e.g for fee events or smart contract events)

So far, the event model looks like:

syntax = "proto3";

package token_transfer;

// Enum for EventType
enum EventType {
    TRANSFER = 0;
    MINT = 1;
    BURN = 2;
    CLAWBACK = 3;
    FEE = 4
}

// Address message with oneof for different address types
message Address {
    oneof address_type {
        string smartContractAddress = 1; // Smart Contract address
        string accountAddress = 2;       // Account address
        string liquidityPoolHash = 3;   // Liquidity Pool hash
        string claimableBalanceId = 4;  // Claimable Balance ID
    }
}

// EventMeta message
message EventMeta {
    uint32 ledgerSequence = 1;
    google.protobuf.Timestamp closedAt = 2; 
    optional uint32 operationIndex = 3;              // Optional field
    string txHash = 4;                      // Stellar transaction hash
}

// TokenTransferEvent message
message TokenTransferEvent {
    EventType eventType = 1;
    EventMeta meta = 2;
     .....
     .....
     .....
     // More fields to follow
}

A sample EventMeta (in json) might look like

{
    "ledgerSequence": 5000,
    "closed_at": "2023-11-20T19:04:26Z",
    "txHash": "63126304befee9417ae18e025620725e56fafe7b0ec69f8541e8915734348d43",
    "operationIndex": 5
}

** Note: The above representation is merely a Golang Struct representation of what the TokenTransferEvent would look like. Field names might change


Fee Event

{
    "eventType": "Fee",
    "from": "AccountA",
    "asset": "native",
    "amount": "1.1"
}
  • from is the account paying the fee - either the fee bump fee account or the transaction source account.

Classic Operations and Events

This section highlights the kind of events that are generated for each operation, along with examples wherever required.
The relevant fields in the events are represented as a JSON for clarity and brevity.
The final section will attempt to accumulate all the different kinds of events in a single data model/proto definition

For all classic operations, the following 2 fields will be present in the event itself.

  • amount - the total amount of the asset being moved
  • asset - the canonical name of the classic asset in the format <AssetCode>:<Issuer> or native for XLM

Important Things to note:

  • Depending on the operation, the from and to fields might be either an account address or a liquidityPoolHash or a ClaimableBalanceId
  • Depending on the type of event, only one of from or to could be present in the event - for e.g mint and burn events

** The EventMeta section is skipped for illustration purposes. It will however be present in all events as mentioned in the section above.

  1. Create Account
{
    "eventType": "Transfer",
    "from": "AccountA",
    "to": "AccountB",
    "asset": "native",
    "amount": "12345"
}
  • from is the account being debited (creator).
  • to is the account being credited (created).
  • amount is the starting native balance (in XLM).
  1. Merge Account
{
    "eventType": "Transfer",
    "from": "AccountA",
    "to": "AccountB",
    "asset": "native",
    "amount": "12345"
}
  • from is the account being debited (merged).
  • to is the account being credited (merged into).
  • amount is the merged native balance.
    ** Note: you can only call Merge Account when the source account does not have any trustlines. So the asset when MergeAccount is called will always be native XLM
  1. Simple Payment
    Suppose AccountA is sending USDC to AccountB
  • If AccountA nor AccountB is the issuer of USDC, then a Transfer event will be generated
{
    "eventType": "Transfer",
    "from": "AccountA",
    "to": "AccountB",
    "asset": "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN",
    "amount": "12345"
}
  • If AccountA happens to be the issuer of USDC, then a Mint event would be generated
{
    "eventType": "Mint",
    "to": "AccountB",
    "asset": "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN",
    "amount": "12345"
}
  • If AccountB happens to be the issuer of USDC, then a Burn event would be generated
{
    "eventType": "Burn",
    "from": "AccountA",
    "asset": "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN",
    "amount": "12345"
}
  1. Create Claimable Balance
{
    "eventType": "Transfer",
    "from": "AccountA",
    "to": "ClaimableBalanceId-1",
    "asset": "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN",
    "amount": "12345"
}
  • from is the account being debited.
  • to is the claimable balance being created.
  • amount is the amount moved into the claimable balance
  1. Claim Claimable Balance
{
    "eventType": "Transfer",
    "from": "ClaimableBalanceId-1",
    "to": "AccountB",
    "asset": "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN",
    "amount": "12345"
}
  • from is the claimable balance id.
  • to is the account being credited.
  • amount is the amount moved from the CB to the account
  1. Clawback and Clawback Claimable Balance
{
    "eventType": "Clawback",
    "from": "AccountA or ClaimableBalanceId-1",
    "asset": "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN",
    "amount": "12345"
}
  • from is the account or CB
  • amount is the amount being moved out of the account or CB and burned.
  1. Allow Trust and Set Trustline flags
    Please refer to this section in CAP-67. that talks about creation of a claimable balance when any of these operations revokes authorization from a trustline that deposited into a liquidity pool.
{
    "eventType": "Transfer",
    "from": "ClaimableBalanceId-1",
    "to": "AccountB",
    "asset": "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN",
    "amount": "12345"
}
  • from is the LiquidityPool Hash
  • to is the Claimable Balance being created
  • amount is the amount moved in tho the CB
  1. LiquidityPool Deposit
    Each LiquidityPoolDeposit operation will generate 2 transfer events - one for each asset
[
    {
        "eventType": "Transfer",
        "from": "AccountA",
        "to": "LPhash",
        "asset": "assetA",
        "amount": "12345"
    },
    {
        "eventType": "Transfer",
        "from": "AccountA",
        "to": "LPHash",
        "asset": "assetB",
        "amount": "12345"
    }
]
  • from is the account being debited.
  • to is the liquidity pool being credited.
  • amount is the amount moved into the liquidity pool.
    If the from account happens to be the issuer of one of the assets being moved in the LP, then a Mint event will be emitted instead of a Transfer
  1. LiquidityPool Withdraw
    Each LiquidityPoolWithdraw operation will generate 2 transfer events - one for each asset
[
    {
        "eventType": "Transfer",
        "from": "LPhash",
        "to": "AccountA",
        "asset": "assetA",
        "amount": "12345"
    },
    {
        "eventType": "Transfer",
        "from": "LPhash",
        "to": "AccountA",
        "asset": "assetB",
        "amount": "12345"
    }
]
  • from is the liquidity pool.
  • to is the account being credited.
  • amount is the amount moved out of the liquidity pool.
    If the to account happens to be the issuer of one of the assets being moved in the LP, then a Burn event will be emitted instead of a Transfer
  1. ManageBuy, ManageSell and CreatePassiveSell Offer Operations
    For all these offer operations, the number of transfer events emitted depends on the number of trades.
    In general, Total Events = Trades * 2 , since each trade consists of 2 transfers.
    If there were zero trades, i.e if the offer was not marketable, then no events will be emitted.
    Consider the following example:
    Suppose 3 sell offers of BTC-USDC exist as follows:
    1. offerId1, sellerId = accountX
    2. offerId2, sellerId = accountY
    3. offerId3, sellerId = accountX
      Note that accountX has 2 different offers that are resting for the same trading pair
      Given a ManageBuyOperation operation for BTC-USDC by AccountP, that fills against the 3 existing offers, a total of 6 transfer events will be generated as follows:
[
  {
      "eventType": "Transfer",
      "from": "AccountP",
      "to": "AccountX",
      "asset": "USDC",
      "amount": "12345"
  },
  {
      "eventType": "Transfer",
      "from": "AccountX",
      "to": "AccountP",
      "asset": "BTC",
      "amount": "1"
  },
      {
      "eventType": "Transfer",
      "from": "AccountP",
      "to": "AccountY",
      "asset": "USDC",
      "amount": "12345"
  },
  {
      "eventType": "Transfer",
      "from": "AccountY",
      "to": "AccountP",
      "asset": "BTC",
      "amount": "1.2"
  },
      {
      "eventType": "Transfer",
      "from": "AccountP",
      "to": "AccountX",
      "asset": "BTC",
      "amount": "12345"
  },
  {
      "eventType": "Transfer",
      "from": "AccountX",
      "to": "AccountP",
      "asset": "USDC",
      "amount": "1.5"
  }
]
  • If the from account happens to be the issuer of one of the assets being traded, then a Mint event will be emitted instead of a Transfer
  • If the to account happens to be the issuer of one of the assets being traded, then a Burn event will be emitted instead of a Transfer
  1. Path Payment Strict Send and Path Payment Strict Receive
    A path payment from AccountA to AccountB can cross through either several offers from the different trading pairs, or through different Liquidity Pools, depending on the path specified in the operation.
    The trades within a path payment, regardless of whether it is against an offer or liquidity pool, can be conceptually thought of as a a transfer between the source account and the seller in case of offers or against the Liquidity pool.
    In addition, there will be one final event indicating a transfer of asset between the source and final destination.
    In general, Total Events = (Trades * 2) + 1

For e.g, a path payment operation where AccountA wants to send BTC and wants AccountB to receive ETH with a path [USDC], and suppose it trades against 2 Liquidity pools - LP-BTC-USDC and LP-USDC-ETH, the following 5 events will be emitted:

[
    {
        "eventType": "Transfer",
        "from": "AccountA",
        "to": "LP-BTC-USDC",
        "asset": "BTC",
        "amount": "1.1"
    },
    {
        "eventType": "Transfer",
        "from": "LP-BTC-USDC",
        "to": "AccountA",
        "asset": "USDC",
        "amount": "12345"
    },
        {
        "eventType": "Transfer",
        "from": "AccountA",
        "to": "LP-USDC-ETH",
        "asset": "USDC",
        "amount": "12345"
    },
    {
        "eventType": "Transfer",
        "from": "LP-USDC-ETH",
        "to": "AccountA",
        "asset": "ETH",
        "amount": "5.5"
    },
        {
        "eventType": "Transfer",
        "from": "AccountA",
        "to": "AccountB",
        "asset": "ETH",
        "amount": "5.5"
    }
]
  • If the path payment has an empty path and sendAsset == destAsset, then the operation is effectively a simple payment.
  • If the from account happens to be the issuer of one of the assets being traded, then a Mint event will be emitted instead of a Transfer
  • If the to account happens to be the issuer of one of the assets being traded, then a Burn event will be emitted instead of a Transfer

Stellar Asset Contract (SAC) Events

CAP-46-6(https://github.com/stellar/stellar-protocol/blob/master/core/cap-0046-06.md) defines the interface followed by smart contracts that want to interact with classic tokens.
It also defines the events that will be emitted when asset movement happens.

  1. Transfer
    {
        "eventType": "Transfer",
        "from": "Account or ContractId",
        "to": "Account or Contract Id",
        "asset": "<AssetCode>:<Issuer>",
        "amount": "1.1"
    }
  1. Mint
    {
        "eventType": "Mint",
        "to": "Account or Contract Id",
        "asset": "<AssetCode>:<Issuer>",
        "amount": "1.1"
    }
  1. Burn
    {
        "eventType": "Burn",
        "from": "Account or ContractId",
        "asset": "<AssetCode>:<Issuer>",
        "amount": "1.1"
    }
  1. Clawback
    {
        "eventType": "Clawback",
        "from": "Account or ContractId",
        "asset": "<AssetCode>:<Issuer>",
        "amount": "1.1"
    }

SEP-41 Compliant Custom Tokens and Events

Smart contracts implementing SEP-41 and custom tokens will emit the following events that will be read by this processor library and surfaced to callers.

  1. Transfer
  {
      "eventType": "Transfer",
      "from": "Address",
      "to": "Address",
      "amount": "1.1"
  }
  1. Mint
  {
      "eventType": "Mint",
      "to": "Address",
      "amount": "1.1"
  }
  1. Burn
  {
      "eventType": "Burn",
      "from": "Address",
      "amount": "1.1"
  }
  1. Clawback
  {
      "eventType": "Clawback",
      "from": "Address",
      "amount": "1.1"
  }

NOTE: The events generated by SEP-41 compliant custom contracts/tokens doesnt emit the asset. It is expected that downstream clients (for e.g Freighter) can identify the custom token being moved externally


Sample Proto Definition for events

Putting it all together, the protobuf definition for the TokenTransferEvent will look something like this:
EDIT: This protobuf definition has been updated to reflect this comment

syntax = "proto3";

package token_transfer;

import "google/protobuf/timestamp.proto";

// Address message with oneof for different address types
message Address {
  oneof address_type {
    string smartContractAddress = 1; // Smart Contract address
    string accountAddress = 2;       // Account address
    string liquidityPoolHash = 3;   // Liquidity Pool hash
    string claimableBalanceId = 4;  // Claimable Balance ID
  }
}

// EventMeta message
message EventMeta {
  uint32 ledgerSequence = 1;
  google.protobuf.Timestamp closedAt = 2;
  /*
   * operationIndex is optional. It wont be present in fee events.
   * Additionally, operationIndex will always be 0 for smart contract transactions
  */
  optional uint32 operationIndex = 3;
  string txHash = 4;                      // Stellar transaction hash
}

message Transfer {
  Address from = 1;
  Address to = 2;
  string amount = 3;
}

message Mint {
  Address to = 1;
  string amount = 2;
}

message Burn {
  Address from = 1;
  string amount = 2;
}

message Clawback {
  Address from = 1;
  string amount = 3;
}

message Fee {
  Address from = 1;
  string amount = 2;
}

// TokenTransfer message
message TokenTransfer {
  EventMeta meta = 1;
  // Asset might not always be presen so marking it as optional, especially in events emitted by SEP-41 custom token contracts
  optional string asset = 2;
  oneof event {
    Transfer transfer = 3;
    Mint mint = 4;
    Burn burn = 5;
    Clawback clawback = 6;
    Fee fee = 7;
  }
}

TokenTransferProcessor and Downstream Usage by Clients

The goal of the TokenTransferProcessor library/package is to provide a list of TokenTransferEvents from a given ledger, transaction, operation.
If downstream clients need further introspection/details, other than the fields present in the TokenTransfer - for e.g more information about smart contracts/operation and result, the clients should be able to derive it themselves using the other helper libraries/helper functions from the ingest package

The proposal is to provide implementation of 3 functions that can be used by downstream clients:


func GetTokenTransferEventsFromLedger(ledger xdr.LedgerCloseMeta) ([]TokenTransferEvent, error) {

	// IMPLEMENTATION DETAIL - TRANSPARENT TO CLIENT
	if (ledger.V < Cap-67-implementedVersion) {
		return processEventsFromOperationAndOperationResult(ledger)
	}
	return processEventsFromUnifiedEventsEmittedByCore(ledger)
}

func GetTokenTransferEventsFromTransaction(ledgerTransaction ingest.LedgerTransaction) ([]TokenTransferEvent, error)

// to be used only for classic operations, since smart contract events are present at the transaction level
func GetTokenTransferEventsFromOperation(transaction ingest.LedgerTransaction, opIndex uint32) ([]TokenTransferEvent, error)

A downstream client, depending on usecase, can invoke these functions in this manner:

// Sample code snippet to show usage of TokenTransferProcessor Library

import (
	"github.com/stellar/go/ingest"
	"github.com/stellar/go/xdr"
	"io"
)
type WrapperOverTokenTransferEvent struct {
	event *TokenTransferEvent
	// ...
	// ...
}

func DownstreamClientLogic(ledger xdr.LedgerCloseMeta, networkPassPhrase string) ([]WrapperOverTokenTransferEvent, error) {

	txReader, err := ingest.NewLedgerTransactionReaderFromLedgerCloseMeta(networkPassPhrase, ledger)
	panicIf(err)
	var events []WrapperOverTokenTransferEvent
	for {
		var transaction ingest.LedgerTransaction
		transaction, err = txReader.Read()
		if err == io.EOF {
			break
		}
                panicIf(err)
		tokenTransferEvents, e := GetTokenTransferEventsFromTransaction(transaction)
		panicIf(e)
		// do some additional processing with tokenTransferEvents fetched from this transaction
		// Leverage helper receiver functions on  `ingest.LedgerTransaction`
		events = append(events, getAdditionalInfoFromTokenTransferEventsInTransaction(tokenTransferEvents, transaction))
	}
	return events, nil

}
@JakeUrban
Copy link
Contributor

Thanks for the detailed writeup @karthikiyer56. A couple questions / comments:

  • How are events structured or grouped? I noticed in some examples the events are contained in an array:
[
    {
        "eventType": "Transfer",
        "from": "LPhash",
        "to": "AccountA",
        "asset": "assetA",
        "amount": "12345"
    },
    {
        "eventType": "Transfer",
        "from": "LPhash",
        "to": "AccountA",
        "asset": "assetB",
        "amount": "12345"
    }
]

Is this array supposed to contain the events for the operation, the transaction, or the ledger? Clients could determine which events were a result of executing a given operation and transaction on their own given the txhash and operation index, so grouping by operation or transaction isn't necessary but I wanted to clarify.

  • What is the recommended approach for clients looking to provide more contextual information on the reason for an event emitted by a smart contract?

For example, if I deposit funds in the Blend protocol, a blend contract will call the token's contract which will emit the transfer event. However, it seems as if this processor will not tell me anything about the invoking contract. Clients like Freighter would like to represent this example as a deposit into blend, rather than a transfer to a contract.

Another example is the consumption of an allowance. According to SEP41, calls to the transfer_from() function of a token result in a transfer event, but Clients like Freighter may want to represent this transfer as the consumption of a previously-granted allowance. In this particular case, the address that spent the allowance wouldn't be represented in the event provided by the processor at all.

What I assume is the answer to both of these questions is that the client will need to inspect the transaction's footprint and auth tree to gather this information itself. This is doable, but I think its worth considering whether this package should implement some of this behavior itself in order to lessen the implementation burden of clients.

@karthikiyer56
Copy link
Contributor Author

karthikiyer56 commented Jan 26, 2025

Re these 2 comments:

How are events structured or grouped? I noticed in some examples the events are contained in an array:

Is this array supposed to contain the events for the operation, the transaction, or the ledger?Clients could determine which events were a result of executing a given operation and transaction on their own given the txhash and operation index......

The grouping you mentioned was simply for illustration purposes, specifically to show that - a path payment operation can emit these 4-5 events, based on who it was filled against

I have added a section about downstream usage by clients to highlight what the library public functions might look like and how a client can use it.

There is no grouping that will be done by the library.
Each function in the library will simply emit a list of TokenTransferEvents.
i.e there will be no grouping to say that these events are grouped by transaction or operation at the top level.
They will be sequential in their appearance in the output list though - All events from operation[0], tx[0] will appear sequentially, followed by events from operation[1]/tx[1], and so on.

Clients can choose to do their own grouping on the fields in the eventMeta
This can be easily done by clients, if needed, based on any of the 3 functions that they choose to call.
As a matter of fact, there are already several helper receiver functions on LedgerTransaction, TransactionEnvelope, and TransactionResult, that can be leveraged for it.
If needed, plaform team can add more

But yes, in a nutshell, extra processing should be done outside the scope of the TokenTransferProcessor package.
This is because we want the TokenTransferProcessor to be as thin wrapper as possible (wishful thinking, since there will be a lot of additional processing logic regardless) for surfacing events emitted by core.


@tamirms
Copy link
Contributor

tamirms commented Jan 27, 2025

@karthikiyer56 overall, looks good! I have a suggestion for a slightly different representation for your protobuf schema. I think each event type could be a different message and the TokenTransferEvent can be a tagged union of one of the event messages:

syntax = "proto3";

package token_transfer;


// Address message with oneof for different address types
message Address {
    oneof address_type {
        string smartContractAddress = 1; // Smart Contract address
        string accountAddress = 2;       // Account address
        string liquidityPoolHash = 3;   // Liquidity Pool hash
        string claimableBalanceId = 4;  // Claimable Balance ID
    }
}

// EventMeta message
message EventMeta {
    uint32 ledgerSequence = 1;
    google.protobuf.Timestamp closedAt = 2; 
    optional uint32 operationIndex = 3;              // Optional field
    string txHash = 4;                      // Stellar transaction hash
}

message Transfer {
    Address from = 1;
    Address to = 2;
    string asset = 3;
    string amount = 4;
}

message Mint {
    Address to = 1;
    string asset = 2;
    string amount = 3;
}

message Burn {
    Address from = 1;
    string asset = 2;
    string amount = 3;
}

message Clawback {
    Address from = 1;
    Address admin = 2;
    string asset = 3;
    string amount = 4;
}

message Fee {
    Address from = 1;
    string amount = 2;
}

// TokenTransfer message
message TokenTransfer {
    EventMeta meta = 1;
    oneof event {
        Transfer transfer = 1;
        Mint mint = 2;
        Burn burn = 3;
        Clawback clawback = 4;
        Fee fee = 5;
    }
}

@tamirms
Copy link
Contributor

tamirms commented Jan 27, 2025

What is the recommended approach for clients looking to provide more contextual information on the reason for an event emitted by a smart contract?

For example, if I deposit funds in the Blend protocol, a blend contract will call the token's contract which will emit the transfer event. However, it seems as if this processor will not tell me anything about the invoking contract. Clients like Freighter would like to represent this example as a deposit into blend, rather than a transfer to a contract.

I think the best approach would be to look at the operation which emitted produced the event. The ledger, transaction hash, and operation index will be part of the event so it will be possible to isolate the operation which produced the event.

Once you have the operation you can look at the contract id and the invocation details (function name and function args). Etherscan displays this info in the Input Data section:

Image

https://etherscan.io/tx/0x4af64d6aa10a2cbab53bd8e9cc1c0c186dd714971b6db814a5103feae287caf4

Additionally you can look at the other soroban events which were emitted by that operation. In the example of depositing into blend I would guess that there is an event emitted by the blend smart contract when you complete that action. However you would need to add support for understanding events on an ad hoc basis. In other words, freighter could add support for Blend but if another smart contract comes along you will need to have more code to support that smart contract.

As @karthikiyer56 mentioned, I think the best place for the code which parses the function invocation or parses additional events should be a separate function / library

@karthikiyer56
Copy link
Contributor Author

karthikiyer56 commented Jan 27, 2025

Re:

@karthikiyer56 overall, looks good! I have a suggestion for a slightly different representation for your protobuf schema.

Updated the proto to reflect that.
Small change - asset is present as an optional field in the top level TokenTransfer event itself

@gouthamp-stellar
Copy link

Hey Karthik, thanks once again for the writeup

I have some follow up questions:



1. I see that for SEP-41 compliant custom tokens you emit events like:


{
"eventType": "Transfer",
"from": "ContractId",
"to": "ContractId",
"amount": "1.1"
}


Is there a reason the from and to are ContractIds and not both ContractIds and AccountIds like you have for SAC events? I’m thinking of use cases like someone transferring a certain amount of a custom token like USDL from one stellar account to another. How would these be modeled?



  1. You noted these SEP-41 custom token events don’t emit an asset and that downstream clients like Freighter can identify the custom token externally. In order to do this, we’d have to call the custom token contract’s name/symbol functions via a simulateTransaction which we’d ideally like to avoid. Can an asset field be added to the event above indicating the name/symbol of the custom token that was transferred? Going back to the USDL example I mentioned in 1., it'd be nice to know that x amount of USDL was transferred between AccountA and AccountB. It’d be nice If the SEP-41 custom token transfer events resembled the SAC token transfer events in this regard 




  2. I’m assuming that GetTokenTransferEventsFromLedger returns token transfer events that happened due to classic transactions, operations and contract invocations in that ledger right? Which means the token transfer events returned by GetTokenTransferEventsFromTransaction and GetTokenTransferEventsFromOperation will be a subset of the token transfer events returned by GetTokenTransferEventsFromLedger? For example, will the txhash in all the events returned by GetTokenTransferEventsFromTransaction be the same?

@karthikiyer56
Copy link
Contributor Author

karthikiyer56 commented Jan 28, 2025

Re:

Is there a reason the from and to are ContractIds and not both ContractIds and AccountIds like you have for SAC events?

I changed the from and to to just say Address in the description.
These are events emitted by a smart contract, so theose from and to fields can really be any addresses that the smart contract's rust code emits as events.


Re:

I’m thinking of use cases like someone transferring a certain amount of a custom token like USDL from one stellar account to another. How would these be modeled?

and

You noted these SEP-41 custom token events don’t emit an asset and that downstream clients like Freighter can identify the custom token externally. In order to do this, we’d have to call the custom token contract’s name/symbol functions via a simulateTransaction which we’d ideally like to avoid. Can an asset field be added to the event above indicating the name/symbol of the custom token that was transferred? Going back to the USDL example I mentioned in 1., it'd be nice to know that x amount of USDL was transferred between AccountA and AccountB. It’d be nice If the SEP-41 custom token transfer events resembled the SAC token transfer events in this regard

Information about custom tokens is unknowable at the ingestion level because of the very fact that they're custom, so they can store information however they want.
Learning that info actually requires invoking and running the functions that the contract exposes, which is untenable at the processor level such as this TokenTransferProcessor.
For e.g if you want to find out how much of the custom token USDL a particular Freighter account has, you would need to call/invoke the smart contract's balance function and provide the address for which you are trying to get balance.
This would have to be done by downstream clients like Freighter.

Can an asset field be added to the event above indicating the name/symbol of the custom token that was transferred?........ It’d be nice If the SEP-41 custom token transfer events resembled the SAC token transfer events in this regard

The answer to this is along the same lines as above.
This is not something that can be gleaned without actually executing a transaction against RPC and calling the pubic name or symbol functions that the custom token contract exposes.
SEP-41 doesnt specify that a contract needs to emit an event with the asset. SAC does that, but SEP-41 doesnt.
Which means that this is not something that the tokenTransferProcessor can deduce somehow from the core event and inject into the TokenTransferEvent.
This is not something that is in the purview of an ingest library as this one.


Re:

I’m assuming that GetTokenTransferEventsFromLedger returns token transfer events that happened due to classic transactions, operations and contract invocations in that ledger right? Which means the token transfer events returned by GetTokenTransferEventsFromTransaction and GetTokenTransferEventsFromOperation will be a subset of the token transfer events returned by GetTokenTransferEventsFromLedger? For example, will the txhash in all the events returned by GetTokenTransferEventsFromTransaction be the same?

Yes to all your questions.
all events that are generated as a part of GetTokenTransferEventsFromTransaction will have the same hash.
they might have different operation indices, if they are from different operations within the same TX.

@Shaptic
Copy link
Contributor

Shaptic commented Jan 28, 2025

In order to do this, we’d have to call the custom token contract’s name/symbol functions via a simulateTransaction which we’d ideally like to avoid.

Unfortunately, so would we 😉 which means the ingestion processor would now also need to run a simulation library and actively execute transactions as part of ingestion to glean this information. Like @karthikiyer56 said - impossible.

A possible "fix" here could be to solve this underlying problem:

SEP-41 doesn't specify that a contract needs to emit an event with the asset. SAC does that, but SEP-41 doesn't.

We could, theoretically, introduce an extension / breaking change to SEP-41 (the event standard for custom tokens) to ask nicely for custom tokens to shove those details into their events, but again that's not something we can do here at the ingestion layer.

@gouthamp-stellar
Copy link

For SEP-41 custom tokens, If the InvokeHostFunction operation corresponding to the token transfer event (which we can get through a combination of ledger, txhash and operationIndex) can give us the contract address: https://github.com/stellar/go/blob/master/xdr/xdr_generated.go#L29459
I'm assuming that calling name() and symbol() on that particular contract will always yield the same result? If so, we may be able to call these functions just once per contract and store a mapping between contract addresses and their name/symbol in the wallet backend

@gouthamp-stellar
Copy link

gouthamp-stellar commented Jan 28, 2025

@karthikiyer56 Just to clarify, every event in the list of events emitted by Mange Buy/Sell offer will have a corresponding ManageBuy/Sell offer operation (through a combination of txhash, ledger and operationIndex) right? And the same is true for all the events emitted by Path payment strict send/receive ? All those events will be tied to a PathPaymentStrictSend/Receive operations correct?

@Shaptic
Copy link
Contributor

Shaptic commented Jan 29, 2025

If the InvokeHostFunction operation corresponding to the token transfer event (which we can get through a combination of ledger, txhash and operationIndex) can give us the contract address

If the contract is the source or destination of the transfer, it will be part of the event. Otherwise, if you wanted to identify a "participant" (e.g. intermediary contracts) then yes, you could either inspect the envelope (which would only give you the top-level contract address being invoked, no subcontract calls) or the invocation tree returned by simulation (which would show you all authorized contract invocations), but it wouldn't necessarily be present in the meta because the contract itself didn't "do" anything that had a side-effect on itself.

But this begs a question: if a contract is "involved" in a transfer by happenstance (e.g. contract C transfers XLM from account A to account B, for some reason), do you even care about seeing C? You'd see a transfer(from: A, to: B, asset: native) which seems sufficient to me; it doesn't matter than C facilitated it.

I'm assuming that calling name() and symbol() on that particular contract will always yield the same result?

Technically, they don't have to. There's nothing in SEP-41 explicitly preventing someone from writing the Rust equivalent of

def name() -> str: 
  return random.choice("hey", "now")

or just changing its name at any point after creation, but that'd be a really strange thing to do, so I think it's more than reasonable to assume those values won't change.

@gouthamp-stellar
Copy link

If the contract is the source or destination of the transfer, it will be part of the event. Otherwise, if you wanted to identify a "participant" (e.g. intermediary contracts) then yes, you could either inspect the envelope (which would only give you the top-level contract address being invoked, no subcontract calls) or the invocation tree returned by simulation (which would show you all authorized contract invocations), but it wouldn't necessarily be present in the meta because the contract itself didn't "do" anything that had a side-effect on itself.

I want to be able to identify the name/symbol of the custom token that is being transferred in the SEP-41 events, and I'm assuming that I can get that info by calling name/symbol on the contract corresponding to the contract address in the InvokeHost function: https://github.com/stellar/go/blob/master/xdr/xdr_generated.go#L29459

But this begs a question: if a contract is "involved" in a transfer by happenstance (e.g. contract C transfers XLM from account A to account B, for some reason), do you even care about seeing C? You'd see a transfer(from: A, to: B, asset: native) which seems sufficient to me; it doesn't matter than C facilitated it.

You're right, in this case we don't care about C

@tamirms
Copy link
Contributor

tamirms commented Jan 29, 2025

@gouthamp-stellar good call out, we should be including the contract id of the stellar asset / sep-41 token which emitted the event. Once you have the contract id, you can obtain the symbol and name using the simulate transaction endpoint. As @Shaptic mentioned, technically speaking those values are not immutable but practically speaking I think you can cache them in the wallet backend for some period of time to reduce the amount of simulate transaction requests. You can also maintain a static list of token contract id -> name / symbol mapping for the token contracts that are most common and we know for a fact by looking at the source code the name / symbol do not change.

@karthikiyer56 I think it makes sense to include an optional contract id field in the protobuf message:

// TokenTransfer message
message TokenTransfer {
  EventMeta meta = 1;
  // Asset might not always be present so marking it as optional, especially in events emitted by SEP-41 custom token contracts
  optional string asset = 2;
  // Contract id of the SAC / SEP-41 token
  optional string contract_id = 2;
  oneof event {
    Transfer transfer = 3;
    Mint mint = 4;
    Burn burn = 5;
    Clawback clawback = 6;
    Fee fee = 7;
  }
}

@Shaptic
Copy link
Contributor

Shaptic commented Jan 29, 2025

@tamirms if ContractEventType == CONTRACT, which I think is always the case for transfer-esque events, the field will always be present. If we can confirm this it doesn't have to be optional.

@tamirms
Copy link
Contributor

tamirms commented Jan 29, 2025

@Shaptic I was thinking that the contract id could be omitted in the case that the event corresponds to stellar classic activity (e.g. a stellar classic payment which emits a transfer event). But we could make the contract id always present even if the event is emitted by stellar classic because we can derive the stellar asset contract id from the asset name and issuer

@sreuland
Copy link
Contributor

sreuland commented Jan 29, 2025

We could, theoretically, introduce an extension / breaking change to SEP-41 (the event standard for custom tokens) to ask nicely for custom tokens to shove those details into their events, but again that's not something we can do here at the ingestion layer.

yes, that would be good, we could proactively setup for this in the IDL, and the asset string value currently proposed in IDL is somewhat opaque, it requires extra work/know-how for downstream consumers to decode if it's in classical, we could define a bit more schema to describe the asset encoding for more type safety, still preserve the recommend optional aspect:

message ClassicAsset {
    string code = 1;
    string issuer =2;
}

message CustomAsset {
    // clients could set in their own ingest/transform apps, 
    // token transformer could eventually set when available
    google.protobuf.StringValue name =1; // wrapper type, returns null if not set
}

message Asset {
  oneof asset_type {
    ClassicAsset classicAsset = 1; 
    CustomAsset customAsset = 2;      
  }
}

...

message Transfer {
    Address from = 1;
    Address to = 2;
    Asset asset = 3;
    string amount = 4;
}

related to optional aspect, it uses the wrapper type StringValue, if unset, it will decode to null value, whereas string if unset, decodes to empty "".

@gouthamp-stellar
Copy link

gouthamp-stellar commented Jan 29, 2025

I like @sreuland 's idea a lot. The CustomAsset.Name field can be null for now, but if/when the SEP-41 change were to happen, it can start getting filled and we wouldn't have to have a cache/make contract calls to get these values in the wallet backend. It's not a bad idea to have Asset be an actual type as opposed to a string, in case we want to add other fields to it. I do think we still need that contract_id field in the TokenTransfer event in the meantime though, so we can use that to cache/make contract calls to get the token's name etc.

@tamirms
Copy link
Contributor

tamirms commented Jan 29, 2025

but if/when the SEP-41 change were to happen, it can start getting filled and we wouldn't have to have a cache/make contract calls to get these values in the wallet backend.

I think it's very unlikely that SEP-41 will change to include both the token name and symbol in the events payload. IMO it's better for the asset field to represent a Stellar classic asset and for custom tokens the asset field will be absent but there will still be a contract_id present

@leighmcculloch
Copy link
Member

I understand from the meeting today that there isn't an immediate user of the protobuf, and so the product gains / impact to users of introducing protobufs are less clear. I noticed @karthikiyer56 mentioned today that they come with additional work to setup, and to maintain. There's also the cost to upskill folks on it as a new tool.

Would not using protobufs today prevent the processor from adopting protobufs in the future if we needed to? I don't think so. Is that correct? If so we could adopt protobufs at a later date, or applications importing the processor could.

Introducing other "official" serialisation formats is also problematic when it comes to fragmenting integration options, and accidentally creating bespoke data models that over time diverge from the protocol's data models and are difficult to change (e.g. Horizon's API). It should be possible for the wallet backend to use the RPC to stream events when CAP-67 is implemented, but the RPC's API doesn't use this protobuf format or data model. Does that mean the RPC API is insufficient, or need fixing, or are we building two different data models that serve the same purpose?

@gouthamp-stellar
Copy link

gouthamp-stellar commented Jan 30, 2025

I see GetTokenTransferEventsFromTransaction takes an ingest.LedgerTransaction as argument. Is there a way to convert/map either TransactionMetaXDR or any other field in RPC's getTransactions endpoint: https://developers.stellar.org/docs/data/rpc/api-reference/methods/getTransactions

into an ingest.LedgerTransaction? I ask because getTransactions is how wallet backend ingests from rpc. If this cant be done, the way I see it, we have the following options:

  1. We'd have to either get the ledger number from the transaction and call getLedgers which returns an xdr.LedgerCloseMeta which we can then pass into GetTokenTransferEventsFromLedger. But given that theres multiple transactions in a ledger, we'd be potentially replaying the same token transfer events over and over again, unless we kept a record of ledgers whose tokentransfer events we already fetched. Moreover an extra RPC call

  2. We can have the wallet backend ingest by calling geLedgers instead, and feed the resulting xdr.LedgerCloseMeta into GetTokenTransferEventsFromLedger, but then we'd need a way to get a list of all transactions and their corresponding statuses as reflected by the status field in getTransactionsResult (i.e we'd need to reflect failed transactions as well) along with TransactionMetas. We need these to be able to extract additonal state changes not captured by token transfers, and also for TSS

@karthikiyer56
Copy link
Contributor Author

karthikiyer56 commented Jan 30, 2025

@gouthamp-stellar - I am trying to address multiple comments in this one message

Re a few comments about - "how to get name of the token from the contract Id".....
I believe this comment from @tamirms answers your question. The answer being, yes, you can rely on calling the name()/symbol() method on a contractId and then create a 1 time mapping externally that you can use in your own code to identify movement of custom tokens.
To that end, and based on @tamirms 's recommendation, I will add an optional contractId field to the eventMeta, when the event is related to smart contract activity - for both SAC events and custom token events.


Re:

@karthikiyer56 Just to clarify, every event in the list of events emitted by Mange Buy/Sell offer will have a corresponding ManageBuy/Sell offer operation (through a combination of txhash, ledger and operationIndex) right? And the same is true for all the events emitted by Path payment strict send/receive ? All those events will be tied to a PathPaymentStrictSend/Receive operations correct?

the answer is yes.
If a pathPaymentOperation is the 0th operation, in a Tx with hash -abcd. in ledger 1234, and if that operation generates 5 events, all those 5 events will have the combination of operationId=0, txHash=abcd, ledgerSequence=1234 in the resulting TokenTransferEvents


Re addition of custom token asset in the TokenTransferEvent....

I like @sreuland 's idea a lot. The CustomAsset.Name field can be null for now, but if/when the SEP-41 change were to happen, it can start getting filled and we wouldn't have to have a cache/make contract calls to get these values in the wallet backend. It's not a bad idea to have Asset be an actual type as opposed to a string, in case we want to add other fields to it. I do think we still need that contract_id field in the TokenTransfer event in the meantime though, so we can use that to cache/make contract calls to get the token's name etc.

I want to clarify a few things.

  • The asset value as it appears in the TokenTransferEvents will be at the top level as represented in the Sample Proto Definition for events, and not within each subtype of eventType
  • While I agree with @sreuland 's comment about having Asset as a one-of is good for extensibility, I am not sure if SEP-41 is likely to change to include the custom Token name in the event emitted by core. So, I am not sure what to do here. At any rate, this can be extended in the future too.
    For now, to make a distinction between a native asset (XLM) and asset issued by a issuer (like USDC), I am going to change the asset modelling to be like so.
message Asset {
  oneof type {
    bool native = 1;             // Indicates the event involves a native asset.
    issuedAsset issued_asset = 2; // Represents a classic issued asset (assetCode and issuer).
    // If needed, there can be a custom asset
  }
}

message IssuedAsset {
  string asset_code = 1; // The asset code for the classic asset (e.g., "USDC").
  string issuer = 2;     // The issuer of the classic asset.
}

@sreuland
Copy link
Contributor

Would not using protobufs today prevent the processor from adopting protobufs in the future if we needed to? I don't think so. Is that correct? If so we could adopt protobufs at a later date, or applications importing the processor could.

There's also the cost to upskill folks on it as a new tool.

Does that mean the RPC API is insufficient, or need fixing, or are we building two different data models that serve the same purpose?

could use the IDL from the beginning in go, follow similar pattern as the XDR compilation, i.e use the IDL compiler(protoc) locally with an optional new make target, commit the generated stub code and IDL file(.proto) for the application events defined in the IDL to the repo, this should not introduce a dependency on protobufs toolchain to existing targets in go repo.

Using IDL up front in this way may avoid potential breaking change later on the proposed derived application event models which are emitted by these TokenTransferProcessor functions, if we hand roll the interfaces/structs in go for the transfer events now, the IDL gen code done later won't be the same and would get complex to try to adapt into the manual defined artifacts, it looks like cdp and rpc could be used equally as XDR input sources for go TokenTransferProcessor functions.

Image

The IDL for token events can evolve to wider usage externally in other languages, it would entail packaging the transform knowhow from XDR to these transfer events which lives in the go TokenTransferProcessor functions into a process which can run multi-platform, then the serialization aspects of IDL get used in distributed messages, it's not required yet, but something like:

Image

@sreuland
Copy link
Contributor

I am not sure if SEP-41 is likely to change to include the custom Token name in the event emitted

custom token contracts don't have to wait for SEP-41 spec to stipulate this to include their token name in events payload/topics correct? in which case if the event schema in IDL has provision for it, the transfer processors could optimistically check for token name existence from custom contracts in XDR side and if present, then populate it on the transformed IDL event.

@leighmcculloch
Copy link
Member

I am not sure if SEP-41 is likely to change to include the custom Token name in the event emitted by core

There is nothing trustable about the name of custom tokens, there's no guarantee the names will be unique or a custom token won't impersonate a SAC contract.

SAC emits the name in the event because of prior ecosystem dependence on those old names that have the asset code and issuer. For any new system where-ever possible they should ingest based on the contract ID, not the "asset code and issuer" to reference an asset. if they ingest based on contract ID they'll be ingesting a common interface across both SAC and custom tokens.

@sreuland
Copy link
Contributor

For any new system where-ever possible they should ingest based on the contract ID, not the "asset code and issuer" to reference an asset.

should asset be removed from these transfer event models then, to avoid potential foot-gun, promotes the better practice of using contract id for resolving asset/token identification more-so?

@tamirms
Copy link
Contributor

tamirms commented Jan 30, 2025

should asset be removed from these transfer event models then, to avoid potential foot-gun, promotes the better practice of using contract id for resolving asset/token identification more-so?

I don't think so because it is not possible to derive the stellar classic asset from a contract id. I think we should only include the asset details when the transfer events involve stellar classic assets. In the case of custom sep-41 tokens we should omit the asset field

@karthikiyer56
Copy link
Contributor Author

karthikiyer56 commented Jan 31, 2025

Re this comment

I see GetTokenTransferEventsFromTransaction takes an ingest.LedgerTransaction as argument. Is there a way to convert/map either TransactionMetaXDR or any other field in RPC's getTransactions endpoint:....

Had a chat with @gouthamp-stellar about it.
The easiest way for wallet team would be to get LedgerCloseMeta - either from RPC or via CDP, and do something similar to the sample code I have mentioned in section TokenTransferProcessor and Downstream Usage by Clients, wherein you call GetTokenTransferEventsFromTransaction for each transaction in the ledger.

The ingest.LedgerTransaction already has helpers for reading/parsing through Transaction results, operation results etc, and seeing if a transaction failed or not, and those can be leveraged by wallet team, if needed in their downstream client code

@karthikiyer56
Copy link
Contributor Author

karthikiyer56 commented Jan 31, 2025

@leighmcculloch : Addressing your comments here

Re:

I understand from the meeting today that there isn't an immediate user of the protobuf, and so the product gains / impact to users of introducing protobufs are less clear

Yes, currently there is no consumer for the protobuf. The wallet team, for whom we are building this token transfer processor, will be building their backend in golang and even if this TokenTransferEvent were to be built in golang (with structs and interfaces), it would serve the wallet team's purposes.
That being said, if for some other user from the ecosystem, who might want to derive the sentiment of token transfer events/asset movement from a ledger, and whose codebase might be in some other lang - java for e.g, there is no out.
They would either have to understand the nuances of how the processTokenTransferEventsFromXXX() works to derive these token events and implement something similar in their own lang. And there lies a lot of scope for error during this implementation.

With protos, in a way we solve a big part of the problem.
If we were to leverage CDP, and create a CLI/binary that serves as an entry point for the new set of processors that platform team is going to be working on, then we alleviate the need for users to be aware of the internals of how the processor generates the event.
for e.g, a sample usage can be like so:

stellar-proccessor run --processor-name=token_transfer --starting-ledger=100 --ending-ledger=200 --<some settings to get the ledger either from GCS or run captive-core> --output-file <local file systempath> OR S3://location OR GCS location

The stellar-processor can be a golang-cli binary that platform team write.
We can leverage this ApplyLedgerMetadata construct or something similar and publish to a location based on user input config. Itcould very well be a kafka topic that can be passed as input args to this cli tool)

The invoker of the CLI doesnt have to worry about golang code or deal with nuances of ingest library/captive core etc etc.
All that the invoker of this cli tool needs to know is that the output of this process will generate a list of events and each event therein conforms to the protobuf definition as described by token_transfer_event.proto, and they can generate their own bindings in whatever language to deserialize this into.

I believe @sreuland is also alluding to the same sentiment in this message


Re:

@karthikiyer56 mentioned today that they come with additional work to setup, and to maintain. There's also the cost to upskill folks on it as a new tool.

By additional work, all I meant is that we would have to run a make target to generate .pb.go files from the protos when we update the protos or introduce newer protos for newer processors.
No different from what we would do today- everytime xdr changes are made by core.
The make targets and a sample PoC is already been done by me here: #5582
Wrto cost to upskill folks, I'd argue that it is no different than someone new trying to get up to speed with xdr format and out xdr_generated.go file.


Re:

Would not using protobufs today prevent the processor from adopting protobufs in the future if we needed to? I don't think so. Is that correct? If so we could adopt protobufs at a later date, or applications importing the processor could

The wallet-team's (the first users of this token transfer library) codebase is in golang itself.
From their perspective, it doesnt matter whether:
(a) we write the tokenTransferEvent model in golang ourselves or
(b) whether we write them in protos and then generate .pb.go files from the proto definitions.

If we do (a), and then decide to pivot to use (b) on a later date, then changes will have to be done in 2 places - in the processor code we write and in the downstream code that wallet team uses.
In that sense, yes, it is labour intensive to do option (a) now and then move to option (b) at a later date.


Re:

Introducing other "official" serialisation formats is also problematic when it comes to fragmenting integration options, and accidentally creating bespoke data models that over time diverge from the protocol's data models and are difficult to change (e.g. Horizon's API)

I dont agree with the notion of introducing another mode of serialization as being a problematic change.
The entire work of processors archive/library , that the platform team is planning on, is net-new, independent of horizon/RPC and for use, primarily with CDP.
@mollykarcher @tamirms - please correct me if I am wrong
i.e given a ledger, derive some events from it - either state change or balance update events or smart contract events, depending upon the processor.
There is no stipulation, atleast IMHO, for it to conform to what the RPC is servicing via its getEvents endpoints.
If we conform to using protos for this new work of processors, we are effectively hiding the abstraction of xdr altogether within our own processor implementations.
Meaning only platform team/SDF folks need to know about xdr and internal workings of core.
All that is visible externally is protos and people are free to deserialize and work with them in whatever language, so long as they know what the corresponding output proto message schema is.


Re:

It should be possible for the wallet backend to use the RPC to stream events when CAP-67 is implemented, but the RPC's API doesn't use this protobuf format or data model. Does that mean the RPC API is insufficient, or need fixing, or are we building two different data models that serve the same purpose?

There are a few problems with the premise that you state here.
Firstly, wallet team wants more data than what is emitted by core event. So the data model currently emitted by getEvents, as it stands today is insufficient
Secondly, currently the getEvents works only for smart contract events.
once CAP-67 is implemented and classic events are introduced in the mix, I am not even sure how the data model for the getEvents will look like to account for both.
Third, even if RPC's getEvents is remodelled/augmented somehow to capture all of classic events + smart contract events, that still doesnt solve the problem of historical ingestion.
Wallet team, potentially wants to ingest ledger either from beginning of time or atleast a year back in time - neither of which might be possible for RPC.
Lastly, the work of the new processors is looking beyond the needs of the wallet-team.
It is catered towards users who want to leverage CDP, and not RPC/Horizon, to derive meaningful data - in the case of tokenTransfer processor, that is events that capture asset movement.

@mollykarcher
Copy link
Contributor

I see GetTokenTransferEventsFromTransaction takes an ingest.LedgerTransaction as argument. Is there a way to convert/map either TransactionMetaXDR or any other field in RPC's getTransactions endpoint: https://developers.stellar.org/docs/data/rpc/api-reference/methods/getTransactions
into an ingest.LedgerTransaction? I ask because getTransactions is how wallet backend ingests from rpc.

I think we can/should enable this, and it interplays with work we already expect/plan to do: #5571

@mollykarcher
Copy link
Contributor

Firstly, wallet team wants more data than what is emitted by core event. So the data model currently emitted by getEvents, as it stands today is insufficient

Let's get very specific here. What is the additional data they need? It wouldn't be unreasonable to bring the discussion of this over to CAP-67 and see if those things could be added to the underlying data model. Is it just the name/contractId from the earlier discussion on this issue?

Secondly, currently the getEvents works only for smart contract events.
once CAP-67 is implemented and classic events are introduced in the mix, I am not even sure how the data model for the getEvents will look like to account for both

getEvents will just always output exactly what events Core is emitting. So if the data model for events defined here is identical to Core's, then sourcing your events from getEvents would look identical to sourcing your events from getTransactions/getLedgers + TokenTransferProcessor.

Third, even if RPC's getEvents is remodelled/augmented somehow to capture all of classic events + smart contract events, that still doesnt solve the problem of historical ingestion.

Core committed to retroactively emitting events for older protocol versions on reingestion (see here), so that does solve the problem of historical reingestion.

It is catered towards users who want to leverage CDP, and not RPC/Horizon, to derive meaningful data - in the case of tokenTransfer processor, that is events that capture asset movement.

CDP/RPC are not mutually exclusive, and in fact, we'd like to enable RPC as a LCM source for CDP (see #5571). This means that for example, the wallet backend could use the ingest library and the tokenTransferProcessor, and then interchangeably choose whether their source of LCM is RPC or a Lagoon.

@mollykarcher
Copy link
Contributor

It should be possible for the wallet backend to use the RPC to stream events when CAP-67 is implemented, but the RPC's API doesn't use this protobuf format or data model. Does that mean the RPC API is insufficient, or need fixing, or are we building two different data models that serve the same purpose?

I think it will be possible, but through multiple mechanisms. RPC has getTransactions, getLedgers and getEvents. Presumably, when someone is hooking up RPC as an ingestion source (via #5571) and using the tokenTransferProcessor, they will be using getTransactions or getLedgers under the hood (implementation TBD) to source their LCM. At this point, there will be a lot of logic in the tokenTransferProcessor that is deriving these events from "classic" state changes, because the events don't yet exist.

Then CAP-67 will be implemented. At this point, getEvents and the getLedgers/getTransactions+tokenTransferProcessor combo should be returning an identical payload/data model. If we wanted to, we could simplify the logic within tokenTransferProcessor to rely mostly on events under the hood. If it's not the case that the data models are identical and there is additional data being overlayed onto the events after they are emitted by Core, then clients that cared about that additional data would continue to use the tokenTransferProcessor rather than getEvents. Given this, the RPC getEvents API might be a subset of what the tokenTransferProcessor API will return.

@tamirms tamirms moved this from Needs Review to Done in Platform Scrum Feb 4, 2025
@karthikiyer56
Copy link
Contributor Author

Re:

I think we can/should enable this, and it interplays with work we already expect/plan to do: #5571

The plan, by wallet, atleast for now, is to pass LCMs sourced from RPC's getLedger endpoint and call the functions in tokenTransferProcessor.
If needed, it can be extended to take transacitonEnvelope/transactionResult xdr strings to produce TokenTransferEvents


Re this comment and this comment, I am summarizing what was discussed in an internal meeting

  • The tokenTransferEvent and the output emitted by getEvents - they have the same content, albeit in different models.
    In that sense, there is nothing needed from core team to add more to the events (by more, I mean more than what is already specified in CAP-67). The tokenTransferEvent is more aligned and has a nicer definition that the output of getEvents currently. In that sense, tokenTransferEvent is preferred to getEvents output for folks who want to derive token transfer meaning from LCM.

  • Wallet team currently doesnt call getEvents for any of their work. They call RPC for transaction submission, getting status of transaction. Going forward, they will be calling getLedgers --> call tokenTransferProcessor library functions by passing ledgers fetched --> Get tokenTransferEvents.

  • If needed, and once CAP-67 is implemented and the classic operation events are surfaced to be available in getEvents, platform team can write a thin wrapper function in the library, that takes the output of getEvents, passes it through a function - convertCoreEventsIntoTokenTransferEvents that emits a list of TokenTransferEvents. This can be done, post CAP-67 implementation, should the need arise.
    This is separate from the already planned work that needs to happen in the tokenTransferProcessor functions to read from core events instead of operation and operationResult, once core implements CAP-67 in a future version.

@karthikiyer56
Copy link
Contributor Author

I am marking this ticket as closed, since all the work/events/model has been captured here.

What now remains to be done is to actually implement said library, as as a part of MVP-1, to generate tokenTransferEvents from operation and operationResults. I will be generating separate tickets to track that work

It has been decided by the platform team to use protos for the data modelling.
Nothing changes from the perspective of wallet-team.
The golang generated code will be checked into the go monorepo, and as such, should be available for use by wallet-team

cc @mollykarcher @gouthamp-stellar @JakeUrban

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
Status: Done
Development

No branches or pull requests

8 participants