Skip to main content
Compose tasks can build, sign, and send Solana transactions using Gill, a lightweight Solana client library. Since Compose tasks run in a sandboxed environment, you’ll wire Gill’s RPC transport through the built-in fetch function so that all network activity remains auditable.

Setup

Install Gill in your Compose project:
npm install gill
Add your Solana RPC URL and keypair as secrets in compose.yaml:
name: "my-solana-app"
api_version: "stable"
secrets:
  - SOLANA_RPC_URL
  - SOLANA_KEYPAIR
tasks:
  - path: "./src/tasks/solana-writer.ts"
    name: "solana_writer"
    triggers:
      - type: "http"
        authentication: "none"

Sandboxed RPC transport

Compose tasks cannot use the global fetch — all HTTP requests must go through the provided context.fetch. Create a transport adapter that Gill can use:
import { TaskContext } from "compose";
import { createSolanaRpcFromTransport } from "gill";

function createSandboxedTransport(
  rpcUrl: string,
  sandboxedFetch: TaskContext["fetch"],
) {
  return async <TResponse>({
    payload,
  }: {
    payload: unknown;
    signal?: AbortSignal;
  }): Promise<TResponse> => {
    const result = await sandboxedFetch<TResponse>(rpcUrl, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Accept: "application/json",
      },
      body: JSON.stringify(payload),
    });
    if (result === undefined) {
      throw new Error(`Solana RPC request failed for ${rpcUrl}`);
    }
    return result;
  };
}
Then create an RPC client from the transport:
const transport = createSandboxedTransport(rpcUrl, fetch);
const rpc = createSolanaRpcFromTransport(transport);
This pattern applies to any Solana library that accepts a custom transport or fetch function. The key requirement is routing all network calls through context.fetch so they’re recorded in Compose’s event logs.

Loading a keypair

Store your Solana keypair as a secret in the same JSON byte-array format used by the Solana CLI (the output of solana-keygen). Then load it in your task:
import {
  createKeyPairFromBytes,
  createSignerFromKeyPair,
} from "gill";

const keypairBytes = new Uint8Array(JSON.parse(env.SOLANA_KEYPAIR));
const keyPair = await createKeyPairFromBytes(keypairBytes);
const signer = await createSignerFromKeyPair(keyPair);

Building and sending a transaction

Here’s a complete example that fetches Bitcoin’s price, writes it to a Solana program, and stores the result in a collection:
import { TaskContext } from "compose";
import {
  createSolanaRpcFromTransport,
  createKeyPairFromBytes,
  createSignerFromKeyPair,
  address,
  getAddressEncoder,
  getProgramDerivedAddress,
  createTransaction,
  signTransactionMessageWithSigners,
  getSignatureFromTransaction,
  getBase64EncodedWireTransaction,
  AccountRole,
} from "gill";

const PROGRAM_ID = "4MUYDek4T93NNN9dsRfxRTZc4KznZ1vTTe4vLtoS2AEs";
const SYSTEM_PROGRAM = "11111111111111111111111111111111";
const DEVNET_RPC_URL = "https://api.devnet.solana.com";

// Anchor instruction discriminator for "write" (from IDL)
const WRITE_DISCRIMINATOR = new Uint8Array([235, 116, 91, 200, 206, 170, 144, 120]);

function toBytes32(value: number): Uint8Array {
  const bytes = new Uint8Array(32);
  let remaining = value;
  for (let i = 31; i >= 0 && remaining > 0; i--) {
    bytes[i] = remaining & 0xff;
    remaining = Math.floor(remaining / 256);
  }
  return bytes;
}

function createSandboxedTransport(
  rpcUrl: string,
  sandboxedFetch: TaskContext["fetch"],
) {
  return async <TResponse>({
    payload,
  }: {
    payload: unknown;
    signal?: AbortSignal;
  }): Promise<TResponse> => {
    const result = await sandboxedFetch<TResponse>(rpcUrl, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Accept: "application/json",
      },
      body: JSON.stringify(payload),
    });
    if (result === undefined) {
      throw new Error(`Solana RPC request failed for ${rpcUrl}`);
    }
    return result;
  };
}

export async function main(context: TaskContext) {
  const { fetch, env, collection } = context;

  // --- Solana RPC setup ---
  const rpcUrl = env.SOLANA_RPC_URL || DEVNET_RPC_URL;
  const transport = createSandboxedTransport(rpcUrl, fetch);
  const rpc = createSolanaRpcFromTransport(transport);

  // --- Load signer ---
  const keypairBytes = new Uint8Array(JSON.parse(env.SOLANA_KEYPAIR));
  const keyPair = await createKeyPairFromBytes(keypairBytes);
  const signer = await createSignerFromKeyPair(keyPair);

  // --- Fetch Bitcoin price ---
  const response = await fetch<{ bitcoin: { usd: number } }>(
    "https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd",
    { max_attempts: 3, initial_interval_ms: 1000, backoff_factor: 2 },
  );

  if (!response) {
    throw new Error("Failed to fetch Bitcoin price");
  }

  const bitcoinPrice = response.bitcoin.usd;
  const timestamp = Date.now();

  // --- Build transaction ---
  const key = toBytes32(timestamp);
  const value = toBytes32(Math.round(bitcoinPrice * 100));

  // Derive PDA: seeds = ["data", signer_pubkey, key]
  const addressEncoder = getAddressEncoder();
  const signerPubkeyBytes = addressEncoder.encode(signer.address);

  const [pda] = await getProgramDerivedAddress({
    programAddress: address(PROGRAM_ID),
    seeds: [new TextEncoder().encode("data"), signerPubkeyBytes, key],
  });

  // Instruction data: 8-byte discriminator + 32-byte key + 32-byte value
  const instructionData = new Uint8Array(8 + 32 + 32);
  instructionData.set(WRITE_DISCRIMINATOR, 0);
  instructionData.set(key, 8);
  instructionData.set(value, 40);

  const writeInstruction = {
    programAddress: address(PROGRAM_ID),
    accounts: [
      { address: pda, role: AccountRole.WRITABLE as const },
      { address: signer.address, role: AccountRole.WRITABLE_SIGNER as const },
      { address: address(SYSTEM_PROGRAM), role: AccountRole.READONLY as const },
    ],
    data: instructionData,
  };

  // --- Sign and send ---
  const { value: latestBlockhash } = await rpc.getLatestBlockhash().send();

  const tx = createTransaction({
    version: "legacy",
    feePayer: signer,
    instructions: [writeInstruction],
    latestBlockhash,
  });

  const signedTx = await signTransactionMessageWithSigners(tx);
  const signature = getSignatureFromTransaction(signedTx);

  const encodedTx = getBase64EncodedWireTransaction(signedTx);
  await rpc.sendTransaction(encodedTx, { encoding: "base64" }).send();

  // --- Store result ---
  const priceHistory = await collection("bitcoin_prices");
  const { id } = await priceHistory.insertOne({
    price: bitcoinPrice,
    timestamp,
  });

  return { success: true, signature, price: bitcoinPrice, timestamp, priceId: id };
}

What’s happening

  1. RPC setup — A sandboxed transport wraps context.fetch so Gill’s RPC calls go through Compose’s auditable network layer.
  2. Load signer — The keypair is loaded from a Compose secret and converted into a Gill transaction signer.
  3. Fetch off-chain data — The task fetches Bitcoin’s price from CoinGecko with built-in retry logic.
  4. Derive PDA — A Program Derived Address is computed from seeds so the on-chain program knows where to store the data.
  5. Build instruction — The instruction data is assembled: an 8-byte Anchor discriminator followed by the key and value.
  6. Sign and send — The transaction is created, signed, base64-encoded, and submitted to the Solana RPC.
  7. Store metadata — The price and transaction signature are persisted in a Compose collection for later retrieval.

Next Steps