Getting started

The problem

Hooks is a smart contract API on XRPL, and hooks-rs is a tool to write the smart contract in Rust.

At the time of writing, the only known and established way of writing is to write it in C. However, there are many problems with C as everyone knows: undefined behaviors, weak language intrinsics, bad developer experience, etc.

This is where the robustness of Rust comes into play: we want to code with confidence. We are writing something that potentially affects the balances of many wallets. In that regard, C is not the most optimal choice, because it makes you fall into many undiscovered traps as discussed. Rust is a really good candidate, because it is not only low level but also provides great developer experience and language semantics that naturally guide you to write better and safer code.

This book will walk you through basics as well as how to make the most out of using hooks-rs.

References

Creating a basic hook

#![no_std]
#![no_main]

use hooks_rs::*;

#[no_mangle]
pub extern "C" fn cbak(_: u32) -> i64 {
    0
}

#[no_mangle]
pub extern "C" fn hook(_: u32) -> i64 {
    // Every hook needs to import guard function
    // and use it at least once
    max_iter(1);

    // Log it to the debug stream, which can be inspected on the website or via wss connection
    let _ = trace(b"accept.rs: Called.", b"", DataRepr::AsUTF8);

    // Accept all
    accept(b"accept.rs: Finished.", line!().into());
}

Here's the most basic hook you can write. Let's go through one by one.

First of all, let's talk about #![no_std] and #![no_main] attributes:

#![no_std]: we need this because hooks run in a very limited environment where standard libraries or external function calls are not available. This is equivalent to saying we are not going to import anything from std in Rust. For example, you cannot write:

use std::vec::Vec;

because Vec is from std. Otherwise, Rust has no way of knowing that your hook file has no access to std.

#![no_main]: notice that our hook file doesn't have main function. we don't need main function because we only intend to export two functions only: cbak and hook. Any other function exports are all ignored and actually rejected when the hook file is submitted.

Next, we import everything from hooks-rs:

use hooks_rs::*;

This is the only thing that you need to do to be able to access all of the APIs.

After that, we have #[no_mangle]. This tells the compiler that we don't want the name of the function to be 'mangle'd, which in turn would create dynamic names and cause an XRPL node to fail to call the function from the generated wasm file correctly, because it only knows about cbak and hook, not something like $cbak_125agh4. This is only needed for cbak and hook functions. Usage on any other functions will cause them to be regarded as an export in the resulting webassembly file, which will make the SetHook transaction fail to be validated.

Phew, now we have the first function: cbak.

pub extern "C" fn cbak(_: u32) -> i64 {
    0
}

The presence of this function is actually optional, which means it can be omitted. cbak (short for 'callback') function is called if hook function emits a transaction. We will cover cbak in greater detail in other chapters, so leave it there for now.

pub extern "C" fn hook(_: u32) -> i64 {
    // Every hook needs to import guard function
    // and use it at least once
    max_iter(1);
    ...
}

Now, we have the hook function. The first line inside it cals max_iter. One important characteristic about hooks is that it is NOT turing-complete. This means you somehow want to specify how long or how much a program would run. max_iter is an easy way to do that. Otherwise, you will have to use c::_g, which is a direct call to the external C API. But you will hardly need to use this because every single call to c::_g can be replaced by max_iter.

let _ = trace(b"accept.rs: Called.", b"", DataRepr::AsUTF8);

The next line is a trace call. It's similar to console.log in JavaScript or cout in C++. It is for debugging purposes. At the time of writing, you can establish a manual websocket connection to wss://xahau-test.net/{r-address} or navigate to https://hooks-testnet-v3-debugstream.xrpl-labs.com/ on your browser.

When the hook gets executed, the log should appear on the browser. If you intend to inspect logs from CLI, the easiest way is to install any websocket connection tools liks websocat and just run something like websocat "wss://xahau-test.net/rL36bt3dv4o27hJup1hrKN2XfnzhYUQ5ez" and the logs will start appearing if there's something happening with rL36bt3dv4o27hJup1hrKN2XfnzhYUQ5ez.

Finally, there's an accept call:

accept(b"accept.rs: Finished.", line!().into());

Calling accept would accept the transaction, meaning that it will make the incoming transaction validated as long as there are no other problems. The message accept.rs: Finished. will be recorded in the ledger as one of the transaction details, so people know what happened with this hook when someone ran it. line!().into() is a call to line! macro. It's similar to __LINE__ in C. At compile time, Rust compiler will look at which line this code is populated at, and will replace it with that that line number. In this case, it will be 22. Note that this is primarily for debugging purposes; In production, you will want to use something more meaningful as a hook return code so that you know what happened. This will also be recorded in the ledger.

Helper APIs

This page covers the helper APIs that are peculiar to writing hooks.

Loops and max_iter

Because hooks are not turing complete, you must let the guard checker know your maximum iteration count for the loops you create. For that reason, only while loop is allowed for now.

When you have a loop, you can use max_iter to specify that:

let mut i = 0;
while {
    max_iter(11); // +1 more than the actual iteration count
    i < 10
} {
    // do sth
    i += 1;
}

In contrast, this will get rejected by the guard checker:

let mut i = 0;
while {
    i < 10
} {
    // do sth
    i += 1;
}

max_iter will automatically inject c::_g under the hood, which is the actual c extern function for guarding the loop.

Currently, the only way to create a loop is with while keyword. for-loop like syntax is WIP as it will require a custom macro.

Array comparison

Comparing arrays is a typical operation. But as we just discussed, max_iter imposes a restriction for you to always use a while loop to compare it like this:

const A: &[u8; 14] = b"same same same";
const B: &[u8; 14] = b"same same diff";

let mut i = 0;
while {
    max_iter(A.len() as u32 + 1); // 14
    i < A.len()
} {
    if A[i] != B[i] {
        rollback(b"diff", -1)
    }
    i += 1;
}

But we know that this is time consuming and not really semantic. hooks-rs standard library offers ComparableArray as a solution. You can write:

const A: &[u8; 14] = b"same same same";
const B: &[u8; 14] = b"same same same";

const COMPARABLE_A: ComparableArray<u8, 14> = ComparableArray::new(A);
const COMPARABLE_B: ComparableArray<u8, 14> = ComparableArray::new(B);

if COMPARABLE_A != COMPARABLE_B {
  rollback(b"diff", -1)
}

For a more detailed example, check out examples/array_equality.rs.

Float computation

In hooks, all float values typically need to stay as XFL, which is a format that is specifically designed for XRPL balances.

In order to use it, just use XFL in your hook, like:

let one_over_two = XFL::one().mulratio(false, 1, 2).unwrap();
let one_over_four = XFL::one().mulratio(false, 1, 4).unwrap();

let one_over_eight = (one_over_two * one_over_four).unwrap();

Note that each computation will return Result<XFL> type, so you will need to handle errors for that.

For a more detailed example, check out examples/float.rs.

Coding in no_std

Coding a hook is special, because it gets compiled down to a very restricted WebAssembly.

Therefore, there are certain rules that you need to follow. The overarching rules are as follows:

  1. The WebAssembly hook file cannot contain any other exports than $hook and $cbak.

    Somewhere in your working WebAssembly hook, something similar to these lines of code will exist:

    (func $cbak (type 1) (param i32) (result i64)
    i64.const 0)
    (func $hook (type 1) (param i32) (result i64)
    (local i32 i32 i64 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i64 i64 i64 i64 i64 i64 i64 i64 i64 i64 i64 i64 i64 i64 i64 i64 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32)
    
    ...
    
    (export "cbak" (func 10))
    (export "hook" (func 11))
    

    The moment you want to export some other function, the guard checker will complain and will reject your hook.

  2. The WebAssembly hook file can only use certain functions from env and nothing else. The hook restricts what functions you can use, so the choices are limited to standard library functions like _g that are injected in the execution environment.

But practically, what would these mean for you?

no_std environment

This means that you need to code in no_std environment. You must have noticed that at the every first line of each hook, we write #![no_std]. This is to let the Rust compiler know that we want to code in no_std environment only.

Typically, no_std environment is used for programs where you cannot expect to have a standard operating system with network, file system, etc. This is the case for hooks as well, because it is run in a very isolated wasm runtime.

Now, below are some specific tips and rules for coding in hooks with all of above things in mind.

No imports from std or other external crates

Since the hook is supposed to run in a no_std environment, Rust compiler will complain if you try to import from std. For example, you cannot import and use Vec:

#![no_std]
#![no_main]

// import does not work here due to the following error:
// failed to resolve: use of undeclared crate or module `alloc`
// add `extern crate alloc` to use the `alloc` crate
use alloc::vec::Vec;

#[no_mangle]
pub extern "C" fn cbak(_: u32) -> i64 {
    0
}

#[no_mangle]
pub extern "C" fn hook(_: u32) -> i64 {
    max_iter(1);
    let a = Vec::new();
}

Any other imports except from core will not work. The reason core imports will work is that it only contains the parts that are strictly irrelevant to the operating system. But your choices will be very much limited compared to std, still.

You are also not allowed to use crates that are not no-std for the same reason.

All functions must be inline

Below hook file will be compiled into wasm, but will get rejected by the guard checker:

#![no_std]
#![no_main]

use hooks_rs::*;

#[no_mangle]
pub extern "C" fn cbak(_: u32) -> i64 {
    0
}

#[no_mangle]
pub extern "C" fn hook(_: u32) -> i64 {
    max_iter(1);
    let bar = foo("test");
    0
}

#[no_mangle]
pub fn foo(bar: &str) -> &str {
    bar
}

because it will create a wasm file that looks like:

(export "cbak" (func $cbak))
(export "hook" (func $hook))
(export "foo" (func $foo))

And this is a violation of the aforementioned rule, where no other exports other than $cbak and $hook are alllowed.

This is same for the functions without #[no_mangle] too:

#![no_std]
#![no_main]

use hooks_rs::*;

#[no_mangle]
pub extern "C" fn cbak(_: u32) -> i64 {
    0
}

#[no_mangle]
pub extern "C" fn hook(_: u32) -> i64 {
    max_iter(1);
    let bar = foo(0);
    0 + bar as i64
}

pub fn foo(bar: u32) -> u32 {
    (bar * 2 * 3 / 4) * mul_iter(bar)
}

pub fn mul_iter(bar: u32) -> u32 {
    let mut ret = 1;

    let mut counter = 0;
    while {
        max_iter(11);
        counter < 10
    } {
        counter += 1;
        ret *= bar;
    }
    ret
}

Above hook code might get passed by the guard checker. The reason is that it is up to the Rust compiler to decide if it wants to inline the functions foo and mul_iter, and to further optimize the call by declaring other utility functions as well. Remember, there are only two functions allowed: hook and cbak.

But sometimes, the compiler would produce this webassembly file:

...
(func $cbak (type 1) (param i32) (result i64)
    i64.const 0)
  (func $hook (type 1) (param i32) (result i64)
    (local i32)
    i32.const 0
    i32.const 0
    i32.load offset=1048576
    i32.const 1
    i32.add
    local.tee 1
    i32.store offset=1048576
    local.get 1
    i32.const 1
    call $_g
    drop
    call $_ZN14array_equality3foo17h742873b0c0345c82E
    i64.const 0)
  (func $_ZN14array_equality3foo17h742873b0c0345c82E (type 2)
    call $_ZN14array_equality8mul_iter17h8cf42c9c1cef5607E)
  (func $_ZN14array_equality8mul_iter17h8cf42c9c1cef5607E (type 2)
    (local i32)
    i32.const 0
    i32.const 0
    i32.load offset=1048576
...

We can see that the compiler injects $_ZN14array_equality3foo17h742873b0c0345c82E even if we didn't instruct it to. All sorts of things can happen when you don't explicitly inline the function.

Therefore, the solution is to label all other functions you write with #[inline(always)]:

#![no_std]
#![no_main]

use hooks_rs::*;

#[no_mangle]
pub extern "C" fn cbak(_: u32) -> i64 {
    0
}

#[no_mangle]
pub extern "C" fn hook(_: u32) -> i64 {
    max_iter(1);
    let bar = foo(0);
    0 + bar as i64
}

#[inline(always)]
pub fn foo(bar: u32) -> u32 {
    (bar * 2 * 3 / 4) * mul_iter(bar)
}

#[inline(always)]
pub fn mul_iter(bar: u32) -> u32 {
    let mut ret = 1;

    let mut counter = 0;
    while {
        max_iter(11);
        counter < 10
    } {
        counter += 1;
        ret *= bar;
    }
    ret
}

And this will always create a valid hook.

No direct array declaration when using pointers

Let's say you will want to interact with the underlying C API right away. In that case, you might want to write something like:

let mut buffer = [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,]
unsafe {
  c::hook_account(buffer.as_mut_ptr() as u32, 20 as u32)
}

But the guard checker will reject your hook, because the Rust compiler will inject $memset function into your hook:

(func $_ZN17compiler_builtins3mem6memset17he135806270db418bE (type 3) (param i32 i32 i32) (result i32)
    (local i32 i32 i32)
    block  ;; label = @1
      block  ;; label = @2
        local.get 2
        i32.const 16
        i32.ge_u
        br_if 0 (;@2;)
        local.get 0
        local.set 3
        br 1 (;@1;)
      end
      local.get 0

...

(func $memset (type 3) (param i32 i32 i32) (result i32)
    local.get 0
    local.get 1
    local.get 2
    call $_ZN17compiler_builtins3mem6memset17he135806270db418bE)

Instead, you will need to always use MaybeUninit. This will prevent any memset or memcpy functions from appearing. Here's how you can create a working hook out of the same thing:

let mut uninitialized_buffer: [MaybeUninit<u8>; 20] = MaybeUninit::uninit_array();

let buffer: [u8; 20] = unsafe {
    let result = c::hook_account(uninitialized_buffer.as_mut_ptr() as u32, 20 as u32).into()

    match result {
        Ok(_) => {}
        Err(err) => {
            return Err(err);
        }
    }

    uninitialized_buffer
        .as_ptr()
        .cast::<[u8; BUFFER_LEN]>()
        .read_volatile()
};

// now use buffer
let first = buffer[0]

But for normal occassions where you would not do anything special with the pointer to the array, the resulting wasm file looks fine (needs a bit more research though), so you can just use it like:

// this creates a byte array, but will not be rejected
let hello = b"hello";

// this too
let a = [1,2,3];

let len = hello.len() + a.len();

Fighting for smaller WebAssembly bytesize

You will need to use unsafe pointers. This section is WIP.

Volatile reads

read_volatile should be used when you don't want the compiler to inject some other code for you to optimize under the hood, potentially declaring another function in your code.

An alternative is to use an unsafe pointer, but this comes at the cost of making the code potentially more dangerous.

But unsafe pointer, compared to volatilely-read array, will almost always produce significantly smaller bytesizes. We care about bytesize because this is directly proportional to the fee you need to pay for when you register your hook via SetHook transaction and the fee that the people that want to run your hook will pay. This is partciuarly true when you array is big.

Therefore, it is recommended to use unsafe pointer instead of volatile reads if you care the most about the fee.

Avoiding undefined behavior

Always follow the rules laid out in the Rust documentation when using unsafe functions. This section is WIP.

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.