Skip to main content

Overview

Orquestra’s transaction builder converts API requests into properly formatted Solana transactions using the IDL as a schema.

Build Request Format

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"
}

Build Response Format

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
    }
  }
}
  • 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.

Build docs developers (and LLMs) love