Forward payload in jettons
A jetton transfer may include a forwardPayload to provide custom data for the transaction recipient. This is a convention, not a language feature.
Jetton payload schema
By definition, the TL-B format is (Either Cell ^Cell): one bit plus the corresponding data depending on the bit:
- bit 0 indicates inline payload: all subsequent bits and references;
- bit 1 indicates ref payload: the next reference.
When inline, the payload is positioned at the end of a message.
Some existing jetton implementations do not follow the schema:
- Some allow empty data, no bits at all, which is invalid because at least one bit must exist. An empty payload should be encoded as bit 0 – empty inline payload.
- Some do not verify that no extra data remains after bit 1.
- Error codes vary across implementations.
Canonical payload typing
TL-B (Either X Y) is a union type X | Y in Tolk. This can be defined as:
struct Transfer {
// ...
forwardPayload: RemainingBitsAndRefs | cell
}It is parsed and serialized according to the schema: either bit 0 + inline data, or bit 1 + ref.
This approach can be used for assignment and client metadata. Trade-offs include:
- consumes more gas due to runtime branching (
IFbit 0); - does not verify that no extra data remains after bit 1.
Payload typing cases
The approach to representing a jetton forwardPayload depends on the intended usage and validation requirements.
Proxy data without validation
To proxy any data as-is, use RemainingBitsAndRefs:
struct Transfer {
// ...
forwardPayload: RemainingBitsAndRefs
}Canonical union with validation
To validate a canonical union RemainingBitsAndRefs | cell, ensure that no extra data remains after a ref payload:
struct Transfer {
// ...
forwardPayload: RemainingBitsAndRefs | cell
mustBeEmpty: RemainingBitsAndRefs
}
fun Transfer.validatePayload(self) {
// if extra data exists, throws 9
self.mustBeEmpty.assertEnd()
// if no bits at all, failed with 9 beforehand,
// because the union could not be loaded
}Validation with slices
If gas consumption is critical but validation is required, avoid allocating unions on the stack. Instead, validate a slice and keep it for further serialization:
struct Transfer {
// ...
forwardPayload: ForwardPayload
}
type ForwardPayload = RemainingBitsAndRefs
// validate TL/B `(Either Cell ^Cell)`
fun ForwardPayload.checkIsCorrectTLBEither(self) {
var mutableCopy = self;
// throw 9 if no bits at all ("maybe ref" loads one bit)
if (mutableCopy.loadMaybeRef() != null) {
// if ^Cell, throw 9 if other data exists
mutableCopy.assertEnd()
}
}Custom error codes
To throw custom error codes instead of an error with exit code 9, calling loadMaybeRef() is discouraged.
type ForwardPayload = RemainingBitsAndRefs
struct (0b0) PayloadInline {
data: RemainingBitsAndRefs
}
struct (0b1) PayloadRef {
refData: cell
rest: RemainingBitsAndRefs
}
type PayloadInlineOrRef = PayloadInline | PayloadRef
// validate TL/B `(Either Cell ^Cell)`
fun ForwardPayload.checkIsCorrectTLBEither(self) {
val p = lazy PayloadInlineOrRef.fromSlice(self);
match (p) {
PayloadInline => {
// okay, valid
}
PayloadRef => {
// valid if nothing besides ref exists
assert (p.rest.isEmpty()) throw ERR_EXTRA_BITS
}
else => {
// both not bit '0' and not bit '1' — empty
throw ERR_EMPTY_PAYLOAD_FIELD
}
}
}Dynamic assignment
Keeping a remainder reduces gas usage and enables validation, but it is less convenient when a payload must be assigned dynamically. The remainder is a plain slice containing an encoded union. For example, creating a ref payload from a cell requires manual construction.
fun createRefPayload(ref: cell) {
// not like this, mismatched types
val err1 = ref;
// not like this, incorrect logic
val err2 = ref.beginParse();
// but like this: '1' + ref
val payload = beginCell()
.storeBool(true).storeRef(ref)
.toSlice();
}Using RemainingBitsAndRefs | cell remains convenient for assignment but may incur additional gas costs.
Last updated on