Writing your first transaction

Writing a transaction on your own is quite a challenging process, as it requires some low-level understanding of how transactions are structured.

Let's go through the steps one by one by taking an example of an XRP payment transaction, which is already available in the library and as an example.

Serialization format

Every XRPL transaction can be represented in binary format as well as JSON format. For this tutorial, we are most interested in the binary format, because we can only submit bytes to emit function to emit the transaction.

Here's one example of a transaction in JSON and binary format, taken from XRPL documentation directly:

JSON

{
  "Account": "rMBzp8CgpE441cp5PVyA9rpVV7oT8hP3ys",
  "Expiration": 595640108,
  "Fee": "10",
  "Flags": 524288,
  "OfferSequence": 1752791,
  "Sequence": 1752792,
  "SigningPubKey": "03EE83BB432547885C219634A1BC407A9DB0474145D69737D09CCDC63E1DEE7FE3",
  "TakerGets": "15000000000",
  "TakerPays": {
    "currency": "USD",
    "issuer": "rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B",
    "value": "7072.8"
  },
  "TransactionType": "OfferCreate",
  "TxnSignature": "30440220143759437C04F7B61F012563AFE90D8DAFC46E86035E1D965A9CED282C97D4CE02204CFD241E86F17E011298FC1A39B63386C74306A5DE047E213B0F29EFA4571C2C",
  "hash": "73734B611DDA23D3F5F62E20A173B78AB8406AC5015094DA53F53D39B9EDB06C"
}

Binary

120007220008000024001ABED82A2380BF2C2019001ABED764D55920AC9391400000000000000000000000000055534400000000000A20B3C85F482532A9578DBB3950B85CA06594D165400000037E11D60068400000000000000A732103EE83BB432547885C219634A1BC407A9DB0474145D69737D09CCDC63E1DEE7FE3744630440220143759437C04F7B61F012563AFE90D8DAFC46E86035E1D965A9CED282C97D4CE02204CFD241E86F17E011298FC1A39B63386C74306A5DE047E213B0F29EFA4571C2C8114DD76483FACDEE26E60D8A586BB58D09F27045C46

How to serialize your transaction

Look for the type of transaction you wish to implement from XRPL transaction types.

Let's say you want to implement payment transaction, which is the most common transaction.

Then, identify the required fields for transaction. In case of the payment transaction, only Amount and Desitnation are the required fields at the time of writing, so we will need to insert these fields in our transaction.

But in case you want to use other optional fields, you need to count them in too.

Transaction common fields

Then, you will also need to insert requied common fields. At the time of writing, Account, TransactionType, Fee, and Sequence are the required common transaction fields. So we will need to serialize these into the resulting binary buffer as well.

Sort the fields in canonical order

So far we've identified these fields:

FieldInternal typeDescription
AmountAmountThe amount of currency to deliver
DestinationAccountIDThe unique address of the account receiving the payment
AccountAccountIDThe unique address of the account that initiated the transaction.
TransactionTypeUInt16The type of transaction. Valid transaction types include: Payment, OfferCreate, TrustSet, and many others.
FeeAmountInteger amount of XRP, in drops, to be destroyed as a cost for distributing this transaction to the network.
SequenceUInt32The sequence number of the account sending the transaction. A transaction is only valid if the sequence number is exactly 1 greater than the previous transaction from the same account.

Now let's learn how to sort these fields.

All fields in a transaction are sorted in a specific order based first on the field's type (specifically, a numeric "type code" assigned to each type), then on the field itself (a "field code").

Each internal type has its own type code. You can find it from SField.h:

enum SerializedTypeID {
    // special types
    STI_UNKNOWN = -2,
    STI_NOTPRESENT = 0,

    // // types (common)
    STI_UINT16 = 1,
    STI_UINT32 = 2,
    STI_UINT64 = 3,
    STI_UINT128 = 4,
    STI_UINT256 = 5,
    STI_AMOUNT = 6,
    STI_VL = 7,
    STI_ACCOUNT = 8,
    // 9-13 are reserved
    STI_OBJECT = 14,
    STI_ARRAY = 15,

    // types (uncommon)
    STI_UINT8 = 16,
    STI_UINT160 = 17,
    STI_PATHSET = 18,
    STI_VECTOR256 = 19,
    STI_UINT96 = 20,
    STI_UINT192 = 21,
    STI_UINT384 = 22,
    STI_UINT512 = 23,
    STI_ISSUE = 24,

    // high level types
    // cannot be serialized inside other types
    STI_TRANSACTION = 10001,
    STI_LEDGERENTRY = 10002,
    STI_VALIDATION = 10003,
    STI_METADATA = 10004,
};

Then, you need to find the field code from SField.cpp:

SField const sfInvalid     = make::one(&sfInvalid, -1);
SField const sfGeneric     = make::one(&sfGeneric, 0);
SField const sfLedgerEntry = make::one(&sfLedgerEntry, STI_LEDGERENTRY, 257, "LedgerEntry");
SField const sfTransaction = make::one(&sfTransaction, STI_TRANSACTION, 257, "Transaction");
SField const sfValidation  = make::one(&sfValidation,  STI_VALIDATION,  257, "Validation");
SField const sfMetadata    = make::one(&sfMetadata,    STI_METADATA,    257, "Metadata");
SField const sfHash        = make::one(&sfHash,        STI_HASH256,     257, "hash");
SField const sfIndex       = make::one(&sfIndex,       STI_HASH256,     258, "index");

// 8-bit integers
SF_U8 const sfCloseResolution   = make::one<SF_U8::type>(&sfCloseResolution,   STI_UINT8, 1, "CloseResolution");
SF_U8 const sfMethod            = make::one<SF_U8::type>(&sfMethod,            STI_UINT8, 2, "Method");
SF_U8 const sfTransactionResult = make::one<SF_U8::type>(&sfTransactionResult, STI_UINT8, 3, "TransactionResult");

// 8-bit integers (uncommon)
SF_U8 const sfTickSize          = make::one<SF_U8::type>(&sfTickSize,          STI_UINT8, 16, "TickSize");

// 16-bit integers
SF_U16 const sfLedgerEntryType = make::one<SF_U16::type>(&sfLedgerEntryType, STI_UINT16, 1, "LedgerEntryType", SField::sMD_Never);
SF_U16 const sfTransactionType = make::one<SF_U16::type>(&sfTransactionType, STI_UINT16, 2, "TransactionType");
SF_U16 const sfSignerWeight    = make::one<SF_U16::type>(&sfSignerWeight,    STI_UINT16, 3, "SignerWeight");

// 32-bit integers (common)
SF_U32 const sfFlags             = make::one<SF_U32::type>(&sfFlags,             STI_UINT32,  2, "Flags");
SF_U32 const sfSourceTag         = make::one<SF_U32::type>(&sfSourceTag,         STI_UINT32,  3, "SourceTag");
SF_U32 const sfSequence          = make::one<SF_U32::type>(&sfSequence,          STI_UINT32,  4, "Sequence");
SF_U32 const sfPreviousTxnLgrSeq = make::one<SF_U32::type>(&sfPreviousTxnLgrSeq, STI_UINT32,  5, "PreviousTxnLgrSeq", SField::sMD_DeleteFinal);
SF_U32 const sfLedgerSequence    = make::one<SF_U32::type>(&sfLedgerSequence,    STI_UINT32,  6, "LedgerSequence");
SF_U32 const sfCloseTime         = make::one<SF_U32::type>(&sfCloseTime,         STI_UINT32,  7, "CloseTime");
SF_U32 const sfParentCloseTime   = make::one<SF_U32::type>(&sfParentCloseTime,   STI_UINT32,  8, "ParentCloseTime");
SF_U32 const sfSigningTime       = make::one<SF_U32::type>(&sfSigningTime,       STI_UINT32,  9, "SigningTime");
SF_U32 const sfExpiration        = make::one<SF_U32::type>(&sfExpiration,        STI_UINT32, 10, "Expiration");
SF_U32 const sfTransferRate      = make::one<SF_U32::type>(&sfTransferRate,      STI_UINT32, 11, "TransferRate");
SF_U32 const sfWalletSize        = make::one<SF_U32::type>(&sfWalletSize,        STI_UINT32, 12, "WalletSize");
SF_U32 const sfOwnerCount        = make::one<SF_U32::type>(&sfOwnerCount,        STI_UINT32, 13, "OwnerCount");
SF_U32 const sfDestinationTag    = make::one<SF_U32::type>(&sfDestinationTag,    STI_UINT32, 14, "DestinationTag");

// 32-bit integers (uncommon)
SF_U32 const sfHighQualityIn       = make::one<SF_U32::type>(&sfHighQualityIn,       STI_UINT32, 16, "HighQualityIn");
SF_U32 const sfHighQualityOut      = make::one<SF_U32::type>(&sfHighQualityOut,      STI_UINT32, 17, "HighQualityOut");
SF_U32 const sfLowQualityIn        = make::one<SF_U32::type>(&sfLowQualityIn,        STI_UINT32, 18, "LowQualityIn");
SF_U32 const sfLowQualityOut       = make::one<SF_U32::type>(&sfLowQualityOut,       STI_UINT32, 19, "LowQualityOut");
SF_U32 const sfQualityIn           = make::one<SF_U32::type>(&sfQualityIn,           STI_UINT32, 20, "QualityIn");
SF_U32 const sfQualityOut          = make::one<SF_U32::type>(&sfQualityOut,          STI_UINT32, 21, "QualityOut");
SF_U32 const sfStampEscrow         = make::one<SF_U32::type>(&sfStampEscrow,         STI_UINT32, 22, "StampEscrow");
SF_U32 const sfBondAmount          = make::one<SF_U32::type>(&sfBondAmount,          STI_UINT32, 23, "BondAmount");
SF_U32 const sfLoadFee             = make::one<SF_U32::type>(&sfLoadFee,             STI_UINT32, 24, "LoadFee");

... and so on

For example, for Amount field, the type code would be STI_AMOUNT = 6 because it has an internal type of Amount, and the field code would be 1.

So if we find all type codes and field codes for all fields and sort them in an ascending order, we get:

FieldInternal typeDescriptionType codeField code
TransactionTypeUInt16The type of transaction. Valid transaction types include: Payment, OfferCreate, TrustSet, and many others.12
SequenceUInt32The sequence number of the account sending the transaction. A transaction is only valid if the sequence number is exactly 1 greater than the previous transaction from the same account.24
AmountAmountThe amount of currency to deliver61
FeeAmountInteger amount of XRP, in drops, to be destroyed as a cost for distributing this transaction to the network.68
AccountAccountIDThe unique address of the account that initiated the transaction.81
DestinationAccountIDThe unique address of the account receiving the payment83

So these fields will be inserted in the binary buffer in this exact order.

Find field ids

Combine a field's type code and field code to get a field ID. This will be prefixed to each field in the buffer.

Although you can manually compute the field ID by following the documentation, you can just use the constants that are already included in the library, as FieldId::sfMyFieldName. For example, a field ID of account is FieldId::Account.

Prefix the length

Some types, specifically at the time of writing, AccountID, and Blob, require the binary field to be prefixed with the information about the field's own length. The length information will be prefixed after the field ID.

The rules are as simple as this:

  • If the field contains 0 to 192 bytes of data, the first byte defines the length of the contents, then that many bytes of data follow immediately after the length byte.
  • If the field contains 193 to 12480 bytes of data, the first two bytes indicate the length of the field with the following formula:
    193 + ((byte1 - 193) * 256) + byte2
    
  • If the field contains 12481 to 918744 bytes of data, the first three bytes indicate the length of the field with the following formula:
    12481 + ((byte1 - 241) * 65536) + (byte2 * 256) + byte3
    

A length-prefixed field cannot contain more than 918744 bytes of data.

For example, the prefixed length for Account field will always be 20 in decimal or 0x14 in hex.

So let's complete the table with all information we have:

FieldInternal typeDescriptionType codeField codeField IDPrefixed length
TransactionTypeUInt16The type of transaction. Valid transaction types include: Payment, OfferCreate, TrustSet, and many others.120x10002 or FieldID::TransactionTypenone
SequenceUInt32The sequence number of the account sending the transaction. A transaction is only valid if the sequence number is exactly 1 greater than the previous transaction from the same account.240x20004 or FieldId::Sequencenone
AmountAmountThe amount of currency to deliver610x60001 or FieldId::Amountnone
FeeAmountInteger amount of XRP, in drops, to be destroyed as a cost for distributing this transaction to the network.680x60008 or FieldId::Feenone
AccountAccountIDThe unique address of the account that initiated the transaction.810x80001 or FieldId::Account0x14
DestinationAccountIDThe unique address of the account receiving the payment830x80001 or FieldId::Account0x14

Now we are really ready to put them into bytes.

Using TransactionBuilder

hooks-rs provides a trait called TransactionBuilder and a struct called TransactionBuffer. It is recommended that you use these to build your own transaction. A detailed example can be found in the crate documentation.