Writing to Solana

This guide walks through your first Solana write from a CRE workflow using the TypeScript SDK. The preferred path is generated bindings: you place your Anchor IDL in the project, run cre generate-bindings solana, and call a typed writeReportFrom... helper. The binding Borsh-encodes your payload, builds the forwarder report, and submits it — you do not hand-roll encoding.

Onchain, the DON does not call your program directly. It submits a signed report to the Keystone Forwarder — a Chainlink-managed Solana program that verifies DON signatures, then cross-program invokes (CPI) your receiver's on_report. That is the same forwarder model as EVM Write; see Solana Write for the full flow. Local simulation uses a Devnet stand-in of that forwarder so you can dry-run without deploying. After you deploy, the live forwarder addresses apply.

For IDL layout, generator output, and what each writeReportFrom... method does under the hood, see Generating Solana Bindings.

Prerequisites

  • A CRE project initialized with cre init. See Part 1: Project Setup if you are starting from scratch.
  • The latest version of the CRE CLI (cre version / cre update); v1.29.0 or later is required for TypeScript Solana binding generation.
  • A funded Solana Devnet keypair for CRE_SOLANA_PRIVATE_KEY (required even for dry-run). Generate one with solana-keygen new, fund it with solana airdrop (or a transfer), and put the 64-byte base58 keypair in your project .env. See Step 4.

What you need: a receiver program

Your Solana program must implement an on_report instruction that accepts the payload delivered by the forwarder. The forwarder CPIs into your program after verifying the DON signatures. Your Anchor IDL's types section defines the struct(s) you will write; the generator turns each into a writeReportFrom<StructName>() method.

This guide uses a minimal cre_docs_receiver program deployed to Solana Devnet at 7k8NypziCPqVY8GYyCw7aqaR5aamFAMdH5ZaHVrZCS94. Its on_report re-derives and verifies the forwarder_authority PDA, then Borsh-decodes the report payload into a UserData { key, value } struct and logs it. It requires no receiver-specific accounts beyond the two the forwarder always supplies (state and forwarder_authority). You can simulate against it without deploying your own program. Swap in your IDL and call the generated helpers for your structs when you are ready.

// cre_docs_receiver on_report (abridged)
pub fn on_report(ctx: Context<OnReport>, metadata: Vec<u8>, report: Vec<u8>) -> Result<()> {
    // ... re-derive + verify forwarder_authority PDA ...
    let user = UserData::deserialize(&mut &report[..])?; // Borsh { key, value }
    msg!("user.key={} user.value={}", user.key, user.value);
    Ok(())
}

#[derive(Accounts)]
pub struct OnReport<'info> {
    /// CHECK: forwarder state; its owner is the forwarder program.
    pub state: UncheckedAccount<'info>,
    /// PDA signer supplied by the forwarder CPI.
    pub forwarder_authority: Signer<'info>,
}

Step 1: Add your IDL and generate bindings

  1. Save the following as contracts/solana/src/idl/cre_docs_receiver.json (or copy your own Anchor IDL into that folder). This is the deployed cre_docs_receiver program's IDL.

    {
      "address": "7k8NypziCPqVY8GYyCw7aqaR5aamFAMdH5ZaHVrZCS94",
      "metadata": {
        "name": "cre_docs_receiver",
        "version": "0.1.0",
        "spec": "0.1.0",
        "description": "Receiver for cre-cli Solana forwarder simulation that logs a decoded UserData payload"
      },
      "instructions": [
        {
          "name": "on_report",
          "discriminator": [214, 173, 18, 221, 173, 148, 151, 208],
          "accounts": [
            { "name": "state" },
            {
              "name": "forwarder_authority",
              "docs": ["PDA signer supplied by the forwarder CPI."],
              "signer": true
            }
          ],
          "args": [
            { "name": "metadata", "type": "bytes" },
            { "name": "report", "type": "bytes" }
          ]
        }
      ],
      "events": [
        {
          "name": "ReceivedUserData",
          "discriminator": [154, 5, 186, 144, 208, 168, 118, 242]
        }
      ],
      "errors": [
        {
          "code": 6000,
          "name": "InvalidForwarderAuthority",
          "msg": "forwarder_authority is not the PDA for this state, receiver, and forwarder program"
        },
        {
          "code": 6001,
          "name": "InvalidUserData",
          "msg": "report payload could not be decoded as UserData"
        }
      ],
      "types": [
        {
          "name": "ReceivedUserData",
          "type": {
            "kind": "struct",
            "fields": [
              { "name": "forwarder", "type": "pubkey" },
              { "name": "forwarder_state", "type": "pubkey" },
              { "name": "forwarder_authority", "type": "pubkey" },
              { "name": "metadata_len", "type": "u32" },
              { "name": "report_len", "type": "u32" },
              { "name": "user", "type": { "defined": { "name": "UserData" } } }
            ]
          }
        },
        {
          "name": "UserData",
          "type": {
            "kind": "struct",
            "fields": [
              { "name": "key", "type": "string" },
              { "name": "value", "type": "string" }
            ]
          }
        }
      ]
    }
    
  2. From your workflow directory, generate the bindings:

    cd my-workflow
    cre generate-bindings solana
    

    This creates contracts/solana/ts/generated/CreDocsReceiver.ts (plus a mock and index.ts). The CreDocsReceiver class includes writeReportFromUserData(runtime, input, remainingAccounts, computeConfig?) and exports the program ID as CRE_DOCS_RECEIVER_PROGRAM_ID.

    The generated bindings import from @solana/codecs and @solana/addresses. This workflow also imports @solana/web3.js (for the forwarder_authority PDA derivation) and @solana/codecs-strings (to base58-encode the returned transaction signature). If you run tsc --noEmit outside of cre workflow simulate, install them in your workflow folder: npm install @solana/codecs @solana/addresses @solana/web3.js @solana/codecs-strings.

Step 2: Configure the workflow

  1. Open (or create) config.staging.json and add the Solana Devnet simulation parameters.

    For cre workflow simulate, use these mock forwarder program and state accounts for Solana Devnet. receiverProgramId matches the IDL address above for this example; with your own program, use your Devnet program ID.

    {
      "schedule": "*/30 * * * * *",
      "chainSelector": "16423721717087811551",
      "receiverProgramId": "7k8NypziCPqVY8GYyCw7aqaR5aamFAMdH5ZaHVrZCS94",
      "forwarderProgramId": "7kuEAA3mSC1Tz8gQjnvH7bKFda9xSPRRin9SZbH49cNK",
      "forwarderState": "5Tipz3yhTBdVsDbaBxZkrp7Gjf3brGq5SKkxReefPMP7"
    }
    
    FieldDescription
    chainSelectorSolana Devnet ("16423721717087811551"). Store as a string to avoid JSON precision loss.
    receiverProgramIdYour receiver program (IDL address for this example)
    forwarderProgramIdForwarder program ID used for local simulation (fixed Devnet value)
    forwarderStateForwarder state account used for local simulation (fixed Devnet value)

    The workflow derives forwarderAuthority at runtime from ["forwarder", forwarderState, receiverProgram] under forwarderProgramId. When you deploy in Step 5, keep that derivation and point forwarderProgramId / forwarderState at the live forwarder addresses for your network.

Simulation vs production forwarder addresses

The forwarder program and state accounts differ between local simulation and production. The config above uses the mock forwarder addresses for cre workflow simulate. When you deploy to the DON, you must replace them with the live Keystone Forwarder addresses for your network.

Simulation (mock forwarder)

These are the mock forwarder addresses for cre workflow simulate. They are the values shown in the config.staging.json above:

NetworkMock Forwarder Program IDMock Forwarder State Account
Solana Devnet7kuEAA3mSC1Tz8gQjnvH7bKFda9xSPRRin9SZbH49cNK5Tipz3yhTBdVsDbaBxZkrp7Gjf3brGq5SKkxReefPMP7
Solana Mainnet7kuEAA3mSC1Tz8gQjnvH7bKFda9xSPRRin9SZbH49cNKjhCjuD4Z3V7HeSUChMRpkRwpw6B9yC63mxDMv8SdLNX

Production (Keystone Forwarder)

For production deployments, use these live Keystone Forwarder addresses:

NetworkKeystone Forwarder Program IDKeystone Forwarder State Account
Solana DevnetCXsKEJcs25TQEYU2e5jZ8QTPE3ffMLZhH6BWHrdcCCB58QoomCQyPSkJ8WopJbX9B4HyvrFzziwvJdU8hZE6DCr9
Solana MainnetGFrSSvQXaVGkc6Nrr8y2msie6pivJkR1s2EnDk4et2949FgdPyU28bGMCuJyD34pzw9W7Ys36ziLbT9cbZtsaraV

The workflow still derives forwarderAuthority from ["forwarder", forwarderState, receiverProgram] under the production forwarderProgramId.

Step 3: Write the workflow

This step is a complete, pasteable workflow that performs one Solana write on a cron trigger using generated bindings. It reads config, derives the forwarder authority PDA, builds the remainingAccounts list, and calls writeReportFromUserData(). Replace CreDocsReceiver / UserData with your generated class and struct when you use your own IDL.

  1. Save the following as my-workflow/main.ts.

    // my-workflow/main.ts
    import { CronCapability, handler, Runner, SolanaClient, solanaAccountMeta, type Runtime } from "@chainlink/cre-sdk"
    import { getBase58Decoder } from "@solana/codecs-strings"
    import { PublicKey } from "@solana/web3.js"
    import { CreDocsReceiver } from "./contracts/solana/ts/generated/CreDocsReceiver"
    
    type Config = {
      schedule: string
      chainSelector: string
      receiverProgramId: string
      forwarderState: string
      forwarderProgramId: string
    }
    
    // PDA seed literal used by the forwarder to derive the forwarder_authority.
    const FORWARDER_SEED = Uint8Array.from("forwarder", (_, i) => "forwarder".charCodeAt(i))
    
    // TxStatus / ReceiverContractExecutionStatus enum → readable labels.
    const TX_STATUS_LABELS: Record<number, string> = { 0: "FATAL", 1: "ABORTED", 2: "SUCCESS" }
    const RECEIVER_EXEC_LABELS: Record<number, string> = { 0: "SUCCESS", 1: "REVERTED" }
    
    const onCronTrigger = (runtime: Runtime<Config>) => {
      const config = runtime.config
    
      const client = new SolanaClient(BigInt(config.chainSelector))
    
      // Receiver program id comes from config here. The binding also exports the
      // program id baked in from the IDL `address` (CRE_DOCS_RECEIVER_PROGRAM_ID),
      // so you can use that instead of threading it through config if you prefer.
      const ds = new CreDocsReceiver(client, config.receiverProgramId)
    
      // Derive the forwarder_authority PDA the same way the on-chain forwarder /
      // receiver do: seeds = ["forwarder", forwarderState, receiverProgram],
      // program = forwarderProgram.
      const forwarderState = new PublicKey(config.forwarderState)
      const forwarderProgram = new PublicKey(config.forwarderProgramId)
      const receiverProgram = new PublicKey(config.receiverProgramId)
      const [forwarderAuthority] = PublicKey.findProgramAddressSync(
        [FORWARDER_SEED, forwarderState.toBytes(), receiverProgram.toBytes()],
        forwarderProgram
      )
    
      // remainingAccounts layout:
      //   index 0: forwarderState, index 1: forwarderAuthority, index 2+: receiver accounts.
      // The full list is hashed into the report; the Solana write capability consumes
      // indices 0/1 and forwards index 2+ to your program's on_report CPI.
      // cre_docs_receiver needs no receiver-specific accounts, so we pass just [state, authority].
      const remainingAccounts = [
        solanaAccountMeta(forwarderState.toBase58(), false),
        solanaAccountMeta(forwarderAuthority.toBase58(), false),
        // Append any receiver-specific accounts your program requires at index 2+, e.g.:
        // solanaAccountMeta(myWritableStatePda.toBase58(), true),
      ]
    
      runtime.log("Submitting UserData to Solana cre_docs_receiver program")
    
      // Solana simulate requires a non-nil computeConfig with computeLimit > 0
      const result = ds.writeReportFromUserData(runtime, { key: "price", value: "500000" }, remainingAccounts, {
        computeLimit: 200_000,
      })
    
      const hasSignature = !!result.txSignature && result.txSignature.length > 0
      const txStatusLabel = TX_STATUS_LABELS[result.txStatus] ?? `UNKNOWN(${result.txStatus})`
      const execStatusLabel =
        result.receiverContractExecutionStatus === undefined
          ? "(n/a)"
          : (RECEIVER_EXEC_LABELS[result.receiverContractExecutionStatus] ??
            `UNKNOWN(${result.receiverContractExecutionStatus})`)
    
      runtime.log("Solana write result:")
      runtime.log(`  txStatus:       ${txStatusLabel} (${result.txStatus})`)
      runtime.log(`  receiverStatus: ${execStatusLabel}`)
      runtime.log(`  transactionFee: ${result.transactionFee ?? 0n} lamports`)
      if (hasSignature) {
        const txHash = getBase58Decoder().decode(result.txSignature!)
        runtime.log(`  txHash:         ${txHash}`)
        runtime.log(`  explorer:       https://explorer.solana.com/tx/${txHash}?cluster=devnet`)
      } else {
        runtime.log("  txHash:         (not broadcast — simulation does not submit the transaction)")
      }
      if (result.errorMessage) {
        runtime.log(`  errorMessage:   ${result.errorMessage}`)
      }
    
      return result
    }
    
    export const initWorkflow = (config: Config) => {
      const cron = new CronCapability()
      return [handler(cron.trigger({ schedule: config.schedule }), onCronTrigger)]
    }
    
    export async function main() {
      const runner = await Runner.newRunner<Config>()
      await runner.run(initWorkflow)
    }
    

Step 4: Run the simulation

Use this step to confirm compile, trigger, and the bindings write path with cre workflow simulate.

  1. Create a workflow.yaml in your workflow folder so the CLI can find your staging target.

    staging-settings:
      user-workflow:
        workflow-name: "my-solana-write"
      workflow-artifacts:
        workflow-path: "./main.ts"
        config-path: "./config.staging.json"
        secrets-path: ""
    
  2. Add a Solana RPC endpoint to your project.yaml (at your project root, not inside my-workflow/) under your simulation target.

    staging-settings:
      rpcs:
        - chain-name: solana-devnet
          url: https://api.devnet.solana.com
    
  3. Set CRE_SOLANA_PRIVATE_KEY in your project .env.

    Solana simulation requires a 64-byte base58 keypair even for dry-run. The transmitter account must exist and be funded on Solana Devnet. Generate a key with solana-keygen new, fund it with solana airdrop (or a transfer), and put the keypair in .env:

    CRE_SOLANA_PRIVATE_KEY=<your-64-byte-base58-keypair>
    
  4. Run the dry-run simulation.

    cre workflow simulate my-workflow --target staging-settings
    

    Because cre_docs_receiver is deployed and the account list matches what the forwarder expects, the write completes successfully (txStatus: SUCCESS, receiverStatus: SUCCESS). Simulation does not broadcast the transaction here, so there is no txSignature / explorer link in the dry-run.

    ✓ Workflow compiled
    [SIMULATION] Simulator Initialized
    [SIMULATION] Running trigger trigger=cron-trigger@1.0.0
    [USER LOG] Submitting UserData to Solana cre_docs_receiver program
    [USER LOG] Solana write result:
    [USER LOG]   txStatus:       SUCCESS (2)
    [USER LOG]   receiverStatus: SUCCESS
    [USER LOG]   transactionFee: 15667 lamports
    [USER LOG]   txHash:         (not broadcast — simulation does not submit the transaction)
    
    ✓ Workflow Simulation Result:
    {
      "$typeName": "capabilities.blockchain.solana.v1alpha.WriteReportReply",
      "receiverContractExecutionStatus": 0,
      "transactionFee": 15667,
      "txStatus": 2
    }
    
  5. (Optional) Broadcast the transaction to Solana Devnet.

    By default, simulation dry-runs the write and returns no signature. Add --broadcast to actually submit the transaction through the mock forwarder. Your CRE_SOLANA_PRIVATE_KEY (transmitter) pays the fee, so it must be funded on Devnet.

    cre workflow simulate my-workflow --target staging-settings --broadcast
    

    This time WriteReport returns a real txSignature, and the workflow logs a base58 txHash plus an explorer link:

    [USER LOG] Submitting UserData to Solana cre_docs_receiver program
    [USER LOG] Solana write result:
    [USER LOG]   txStatus:       SUCCESS (2)
    [USER LOG]   receiverStatus: SUCCESS
    [USER LOG]   transactionFee: 5000 lamports
    [USER LOG]   txHash:         c8ifuihaPd2erDuQvees1s2o8ikFmZuNF7WQE63PPGG2SXHZk5vXrHfe7nm4cj4fo7cE3Aq48ksMjP6ugPxAhqn
    [USER LOG]   explorer:       https://explorer.solana.com/tx/c8ifuihaPd2erDuQvees1s2o8ikFmZuNF7WQE63PPGG2SXHZk5vXrHfe7nm4cj4fo7cE3Aq48ksMjP6ugPxAhqn?cluster=devnet
    
    ✓ Workflow Simulation Result:
    {
      "$typeName": "capabilities.blockchain.solana.v1alpha.WriteReportReply",
      "receiverContractExecutionStatus": 0,
      "transactionFee": 5000,
      "txSignature": "Hkwkf/rL44luYyMfBIOJ9JgyLUUFUcMChfbqDTGcCND3zN9RziakjeWeywBkcjgXpYBRc23eVFM0BYeLcuMNBQ==",
      "txStatus": 2
    }
    

    Open the explorer link to confirm the transaction onchain: the mock forwarder is the top-level program and cre_docs_receiver appears as a CPI target. Note txSignature in the JSON result is base64-encoded; the workflow base58-decodes it into the txHash shown in the logs (the form Solana explorers expect).

    When you are ready to run on the DON with your receiver, continue to Step 5.

Step 5: Deploy the workflow

A deployed workflow runs on the DON, which submits reports through the live Keystone Forwarder on Solana Devnet. Prefer the private registry for this example (CRE login session; no Ethereum gas for registry ops). You need Deploy Access.

You can use the cre_docs_receiver example receiver as-is — it's deployed on Devnet and ready to accept writes. If you have your own receiver program, replace receiverProgramId (and append any receiver-specific accounts in the workflow) with your own values.

  1. Create config.production.json. Keep your receiverProgramId and swap in the production Keystone Forwarder addresses:

    {
      "schedule": "30 */5 * * * *",
      "chainSelector": "16423721717087811551",
      "receiverProgramId": "7k8NypziCPqVY8GYyCw7aqaR5aamFAMdH5ZaHVrZCS94",
      "forwarderProgramId": "CXsKEJcs25TQEYU2e5jZ8QTPE3ffMLZhH6BWHrdcCCB5",
      "forwarderState": "8QoomCQyPSkJ8WopJbX9B4HyvrFzziwvJdU8hZE6DCr9"
    }
    

    These are the Solana Devnet production Keystone Forwarder addresses. For Mainnet, see the forwarder address table above. If you are using your own receiver program, replace receiverProgramId with your own value. The workflow still derives forwarderAuthority from ["forwarder", forwarderState, receiverProgram] under that forwarderProgramId.

  2. Add a production-settings target to your existing my-workflow/workflow.yaml (append it below the staging-settings block you created in Step 4 — do not replace the staging block).

    production-settings:
      user-workflow:
        workflow-name: "my-solana-write"
        deployment-registry: "private"
      workflow-artifacts:
        workflow-path: "./main.ts"
        config-path: "./config.production.json"
        secrets-path: ""
    
  3. Add the Solana Devnet RPC under a production-settings block in your project.yaml (at your project root — append it below the existing staging-settings block). The URL can be the same as staging.

  4. Deploy from your project root.

    cre workflow deploy my-workflow --target production-settings
    

    Expected shape:

    Deploying Workflow: my-solana-write
      Registry:      private
      ...
    ✓ Workflow registered in private registry
    ...
         Status:           Active
    
  5. After the cron fires, check that the workflow executed successfully. You can view executions and logs in the CRE platform, or use the CLI. List recent executions:

    cre execution list my-solana-write
    
    Executions
    
    1. 47517057f8ecb7455ddf9eb58d4a635e96bd968158146ed1973f9591cfdc1421
         Workflow:  my-solana-write
         Status:    SUCCESS
         Started:   2026-08-20 19:50:31 UTC
         Finished:  2026-08-20 19:50:43 UTC (12s)
    
  6. View the execution logs to confirm the write reached Solana:

    cre execution logs <execution-id>
    

    Each DON node executes the workflow and logs the write result. A successful execution shows all nodes reaching txStatus: SUCCESS with a real on-chain transaction hash:

    [2026-08-20 19:50:31 UTC] [Node 1] Submitting UserData to Solana cre_docs_receiver program
    [2026-08-20 19:50:31 UTC] [Node 6] Submitting UserData to Solana cre_docs_receiver program
    ...
    [2026-08-20 19:50:43 UTC] [Node 1] Solana write result:
    [2026-08-20 19:50:43 UTC] [Node 1]   txStatus:       SUCCESS (2)
    [2026-08-20 19:50:43 UTC] [Node 1]   receiverStatus: (n/a)
    [2026-08-20 19:50:43 UTC] [Node 1]   transactionFee: 0 lamports
    [2026-08-20 19:50:43 UTC] [Node 1]   txHash:         2dJiCNgbGGhoMd7LSQH9FoUCWwUSzFyP8JHDzNrfcsGZcroSt1rsVPNzgq6LbMG8vjTzJPgeoGaViSABdeqa2v62
    [2026-08-20 19:50:43 UTC] [Node 1]   explorer:       https://explorer.solana.com/tx/2dJiCNgbGGhoMd7LSQH9FoUCWwUSzFyP8JHDzNrfcsGZcroSt1rsVPNzgq6LbMG8vjTzJPgeoGaViSABdeqa2v62?cluster=devnet
    

    Open the explorer link to confirm the transaction onchain: the Keystone Forwarder is the top-level program and cre_docs_receiver appears as a CPI target. All nodes share the same txHash because the DON reaches consensus on a single transaction.

    When you are done testing, pause the workflow to stop it from running:

    cre workflow pause my-workflow
    

See Deploying to the Private Registry for registry details, activate/pause, and CI flags.

What happens onchain

When the deployed workflow runs on the DON:

  1. The DON nodes each execute your workflow and reach consensus on the encoded payload.
  2. A DON node calls the Keystone Forwarder program with the signed report and account list.
  3. The forwarder verifies all DON signatures and re-computes the account hash over the full account list.
  4. The forwarder supplies forwarderState + forwarderAuthority and CPIs into your program's on_report instruction with the decoded payload and your receiver-specific accounts (indices 2+).

The transaction on Solana Explorer will show the forwarder program as the initial callee, with your program appearing as a CPI target.

Next steps

Get the latest Chainlink content straight to your inbox.