Overview
Orquestra’s transaction builder converts API requests into properly formatted Solana transactions using the IDL as a schema.
Clients submit transaction build requests with:
packages/worker/src/services/tx-builder.ts
export interface BuildTransactionRequest {
accounts: Record<string, string> // account name -> public key (base58)
args: Record<string, any> // arg name -> value
feePayer: string // base58 public key
recentBlockhash?: string // optional, will fetch if not provided
network?: 'mainnet-beta' | 'devnet' | 'testnet'
}
Example Request
{
"accounts": {
"user": "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU",
"systemProgram": "11111111111111111111111111111111"
},
"args": {
"amount": 1000000
},
"feePayer": "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU",
"network": "devnet"
}
packages/worker/src/services/tx-builder.ts
export interface BuildTransactionResponse {
transaction: string // base58 serialized transaction message
message: string // human-readable description
accounts: AccountInfo[] // account details used
instruction: InstructionInfo // instruction info
estimatedFee: number // estimated fee in lamports
}
export interface AccountInfo {
name: string
pubkey: string
isSigner: boolean
isWritable: boolean
}
Instruction Discriminator
Anchor uses an 8-byte discriminator to identify instructions:
packages/worker/src/services/tx-builder.ts
// Anchor instruction discriminator: first 8 bytes of SHA-256("global:<instruction_name>")
async function getInstructionDiscriminator(instructionName: string): Promise<Uint8Array> {
const encoder = new TextEncoder()
const data = encoder.encode(`global:${instructionName}`)
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
return new Uint8Array(hashBuffer).slice(0, 8)
}
The discriminator is prepended to the serialized instruction arguments to form the complete
instruction data.
Argument Serialization
Orquestra uses Borsh-like serialization for instruction arguments:
packages/worker/src/services/tx-builder.ts
function encodeArgs(
args: Record<string, any>,
argDefs: Array<{ name: string; type: any }>,
types?: AnchorType[]
): Uint8Array {
const buffers: number[] = []
for (const argDef of argDefs) {
const value = args[argDef.name]
const encoded = encodeValue(value, argDef.type, types)
buffers.push(...encoded)
}
return new Uint8Array(buffers)
}
Type Encoding
Primitive Types
packages/worker/src/services/tx-builder.ts
function encodeValue(value: any, type: any, types?: AnchorType[]): number[] {
if (typeof type === 'string') {
switch (type) {
case 'u8':
return [Number(value) & 0xff]
case 'u16': {
const v = Number(value)
return [v & 0xff, (v >> 8) & 0xff]
}
case 'u32': {
const v = Number(value)
return [v & 0xff, (v >> 8) & 0xff, (v >> 16) & 0xff, (v >> 24) & 0xff]
}
case 'u64': case 'i64': {
const v = BigInt(value)
const bytes: number[] = []
for (let i = 0; i < 8; i++) {
bytes.push(Number((v >> BigInt(i * 8)) & BigInt(0xff)))
}
return bytes
}
case 'bool':
return [value ? 1 : 0]
case 'publicKey':
case 'pubkey': {
return [...base58Decode(String(value))]
}
// ... more types
}
}
}
Supported Primitive Types
- Integers:
u8, u16, u32, u64, u128, i8, i16, i32, i64, i128
- Floats:
f32, f64
- Boolean:
bool
- String:
string (UTF-8 encoded with length prefix)
- Public Key:
publicKey, pubkey (32-byte base58 decoded)
- Bytes:
bytes (variable length with prefix)
Complex Types
packages/worker/src/services/tx-builder.ts
// Option type
if (type.option) {
if (value === null || value === undefined) {
return [0] // None
}
return [1, ...encodeValue(value, type.option, types)] // Some
}
// Vec type
if (type.vec) {
const arr = Array.isArray(value) ? value : []
const len = arr.length
const result = [len & 0xff, (len >> 8) & 0xff, (len >> 16) & 0xff, (len >> 24) & 0xff]
for (const item of arr) {
result.push(...encodeValue(item, type.vec, types))
}
return result
}
// Array type (fixed size)
if (type.array) {
const [innerType, size] = type.array
const arr = Array.isArray(value) ? value : []
const result: number[] = []
for (let i = 0; i < size; i++) {
result.push(...encodeValue(arr[i] || 0, innerType, types))
}
return result
}
Struct Encoding
Custom struct types are encoded field-by-field:
packages/worker/src/services/tx-builder.ts
// Defined type (struct) — look up in IDL types and encode each field in order
const definedName = getDefinedTypeName(type)
if (definedName && types) {
const typeDef = types.find((t) => t.name === definedName)
if (typeDef && typeDef.type?.kind === 'struct' && typeDef.type?.fields) {
const obj = (typeof value === 'object' && value !== null) ? value : {}
const result: number[] = []
for (let i = 0; i < typeDef.type.fields.length; i++) {
const field = normalizeField(typeDef.type.fields[i], i)
const fieldValue = obj[field.name]
result.push(...encodeValue(fieldValue, field.type, types))
}
return result
}
}
Struct fields must be encoded in the exact order defined in the IDL, as Borsh serialization
is position-dependent.
Account Handling
The builder validates and normalizes account metadata:
packages/worker/src/services/tx-builder.ts
// Build account list
const accountInfos: AccountInfo[] = instruction.accounts.map((acc: AnchorAccountMeta) => {
const norm = normalizeAccountMeta(acc)
return {
name: norm.name,
pubkey: request.accounts[norm.name] || '',
isSigner: norm.isSigner,
isWritable: norm.isMut,
}
})
Request Validation
Validates request data against IDL schema:
packages/worker/src/services/tx-builder.ts
export function validateBuildRequest(
instruction: AnchorInstruction,
accounts: Record<string, string>,
args: Record<string, any>,
idlTypes?: AnchorType[],
): { valid: boolean; errors: string[] } {
const errors: string[] = []
// Check required accounts
for (const acc of instruction.accounts) {
const norm = normalizeAccountMeta(acc)
if (!norm.isOptional && !accounts[acc.name]) {
errors.push(`Missing required account: ${acc.name}`)
}
// Validate pubkey format
if (accounts[acc.name] && !isValidBase58(accounts[acc.name])) {
errors.push(`Invalid public key for account "${acc.name}": ${accounts[acc.name]}`)
}
}
// Check required args
for (const arg of instruction.args) {
if (args[arg.name] === undefined) {
errors.push(`Missing required argument: ${arg.name}`)
}
}
return { valid: errors.length === 0, errors }
}
Validation runs before encoding to provide clear error messages to API clients.
Transaction Assembly
The complete build process:
packages/worker/src/services/tx-builder.ts
export async function buildTransaction(
idl: AnchorIDL,
instructionName: string,
request: BuildTransactionRequest,
programId: string,
rpcUrl: string,
): Promise<BuildTransactionResponse> {
const instruction = getInstruction(idl, instructionName)
if (!instruction) {
throw new Error(`Instruction "${instructionName}" not found in IDL`)
}
// Get instruction discriminator
const discriminator = await getInstructionDiscriminator(instructionName)
// Encode arguments
const encodedArgs = encodeArgs(request.args, instruction.args, idl.types)
// Combine discriminator + args
const instructionData = new Uint8Array(discriminator.length + encodedArgs.length)
instructionData.set(discriminator, 0)
instructionData.set(encodedArgs, discriminator.length)
// Fetch recent blockhash if not provided
let blockhash = request.recentBlockhash
if (!blockhash) {
blockhash = await fetchRecentBlockhash(rpcUrl)
}
// Build transaction data
const txData = {
feePayer: request.feePayer,
recentBlockhash: blockhash,
instructions: [{
programId,
keys: accountInfos.map((acc) => ({
pubkey: acc.pubkey,
isSigner: acc.isSigner,
isWritable: acc.isWritable,
})),
data: arrayToBase58(instructionData),
}],
}
const txBytes = new TextEncoder().encode(JSON.stringify(txData))
const txBase58 = base58Encode(txBytes)
return {
transaction: txBase58,
message: `Transaction for ${idl.metadata.name}.${instructionName}`,
accounts: accountInfos,
instruction: {
name: instructionName,
programId,
data: arrayToHex(instructionData),
accounts: accountInfos,
},
estimatedFee: 5000, // base fee in lamports
}
}
Recent Blockhash Fetching
Fetches blockhash from Solana RPC if not provided:
packages/worker/src/services/tx-builder.ts
async function fetchRecentBlockhash(rpcUrl: string): Promise<string> {
try {
const response = await fetch(rpcUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'getLatestBlockhash',
params: [{ commitment: 'finalized' }],
}),
})
const data = await response.json() as any
if (data.result?.value?.blockhash) {
return data.result.value.blockhash
}
throw new Error('Failed to get blockhash from RPC')
} catch (err) {
throw new Error(`Failed to fetch recent blockhash: ${(err as Error).message}`)
}
}
Blockhashes are valid for approximately 150 slots (~60 seconds). The builder always fetches
the latest blockhash unless explicitly provided.
Base58 Serialization
Serializes transaction data to base58 for transmission:
packages/worker/src/services/tx-builder.ts
const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'
function base58Encode(bytes: Uint8Array): string {
// Count leading zeros
let leadingZeros = 0
for (const b of bytes) {
if (b !== 0) break
leadingZeros++
}
// Convert to base58
const digits: number[] = []
for (const byte of bytes) {
let carry = byte
for (let j = 0; j < digits.length; j++) {
carry += digits[j] << 8
digits[j] = carry % 58
carry = (carry / 58) | 0
}
while (carry > 0) {
digits.push(carry % 58)
carry = (carry / 58) | 0
}
}
// Leading '1's for each leading zero byte
let result = '1'.repeat(leadingZeros)
for (let i = digits.length - 1; i >= 0; i--) {
result += BASE58_ALPHABET[digits[i]]
}
return result
}
Base58 encoding is standard for Solana addresses and transaction serialization.
PDA Derivation
PDAs (Program Derived Addresses) can be derived from seed templates:
// Example PDA seeds from IDL
{
"name": "vault",
"pda": {
"seeds": [
{ "kind": "const", "value": [118, 97, 117, 108, 116] }, // "vault" in bytes
{ "kind": "account", "path": "authority" }
]
}
}
The builder can automatically derive PDAs from these templates when building transactions.