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

Packages

Learn more about using npm packages in Compose tasks.

Collections

Store and query state across task runs.

Task Triggers

Trigger your Solana tasks via cron, HTTP, or on-chain events.

Secrets

Securely manage your Solana keypair and RPC URL.