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 Read & 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 withsolana-keygen new, fund it withsolana 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
-
Save the following as
contracts/solana/src/idl/cre_docs_receiver.json(or copy your own Anchor IDL into that folder). This is the deployedcre_docs_receiverprogram'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" } ] } } ] } -
From your workflow directory, generate the bindings:
cd my-workflow cre generate-bindings solanaThis creates
contracts/solana/ts/generated/CreDocsReceiver.ts(plus a mock andindex.ts). TheCreDocsReceiverclass includeswriteReportFromUserData(runtime, input, remainingAccounts, computeConfig?)and exports the program ID asCRE_DOCS_RECEIVER_PROGRAM_ID.The generated bindings import from
@solana/codecsand@solana/addresses. This workflow also imports@solana/web3.js(for theforwarder_authorityPDA derivation) and@solana/codecs-strings(to base58-encode the returned transaction signature). If you runtsc --noEmitoutside ofcre 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
-
Open (or create)
config.staging.jsonand add the Solana Devnet simulation parameters.For
cre workflow simulate, use these mock forwarder program and state accounts for Solana Devnet.receiverProgramIdmatches 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" }Field Description 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
forwarderAuthorityat runtime from["forwarder", forwarderState, receiverProgram]underforwarderProgramId. When you deploy in Step 5, keep that derivation and pointforwarderProgramId/forwarderStateat 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:
| Network | Mock Forwarder Program ID | Mock Forwarder State Account |
|---|---|---|
| Solana Devnet | 7kuEAA3mSC1Tz8gQjnvH7bKFda9xSPRRin9SZbH49cNK | 5Tipz3yhTBdVsDbaBxZkrp7Gjf3brGq5SKkxReefPMP7 |
| Solana Mainnet | 7kuEAA3mSC1Tz8gQjnvH7bKFda9xSPRRin9SZbH49cNK | jhCjuD4Z3V7HeSUChMRpkRwpw6B9yC63mxDMv8SdLNX |
Production (Keystone Forwarder)
For production deployments, use these live Keystone Forwarder addresses:
| Network | Keystone Forwarder Program ID | Keystone Forwarder State Account |
|---|---|---|
| Solana Devnet | CXsKEJcs25TQEYU2e5jZ8QTPE3ffMLZhH6BWHrdcCCB5 | 8QoomCQyPSkJ8WopJbX9B4HyvrFzziwvJdU8hZE6DCr9 |
| Solana Mainnet | GFrSSvQXaVGkc6Nrr8y2msie6pivJkR1s2EnDk4et294 | 9FgdPyU28bGMCuJyD34pzw9W7Ys36ziLbT9cbZtsaraV |
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.
-
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.
-
Create a
workflow.yamlin 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: "" -
Add a Solana RPC endpoint to your
project.yaml(at your project root, not insidemy-workflow/) under your simulation target.staging-settings: rpcs: - chain-name: solana-devnet url: https://api.devnet.solana.com -
Set
CRE_SOLANA_PRIVATE_KEYin 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 withsolana airdrop(or a transfer), and put the keypair in.env:CRE_SOLANA_PRIVATE_KEY=<your-64-byte-base58-keypair> -
Run the dry-run simulation.
cre workflow simulate my-workflow --target staging-settingsBecause
cre_docs_receiveris 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 notxSignature/ 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 } -
(Optional) Broadcast the transaction to Solana Devnet.
By default, simulation dry-runs the write and returns no signature. Add
--broadcastto actually submit the transaction through the mock forwarder. YourCRE_SOLANA_PRIVATE_KEY(transmitter) pays the fee, so it must be funded on Devnet.cre workflow simulate my-workflow --target staging-settings --broadcastThis time
WriteReportreturns a realtxSignature, and the workflow logs a base58txHashplus 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_receiverappears as a CPI target. NotetxSignaturein the JSON result is base64-encoded; the workflow base58-decodes it into thetxHashshown 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.
-
Create
config.production.json. Keep yourreceiverProgramIdand 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
receiverProgramIdwith your own value. The workflow still derivesforwarderAuthorityfrom["forwarder", forwarderState, receiverProgram]under thatforwarderProgramId. -
Add a
production-settingstarget to your existingmy-workflow/workflow.yaml(append it below thestaging-settingsblock 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: "" -
Add the Solana Devnet RPC under a
production-settingsblock in yourproject.yaml(at your project root โ append it below the existingstaging-settingsblock). The URL can be the same as staging. -
Deploy from your project root.
cre workflow deploy my-workflow --target production-settingsExpected shape:
Deploying Workflow: my-solana-write Registry: private ... โ Workflow registered in private registry ... Status: Active -
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-writeExecutions 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) -
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: SUCCESSwith 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=devnetOpen the explorer link to confirm the transaction onchain: the Keystone Forwarder is the top-level program and
cre_docs_receiverappears as a CPI target. All nodes share the sametxHashbecause 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:
- The DON nodes each execute your workflow and reach consensus on the encoded payload.
- A DON node calls the Keystone Forwarder program with the signed report and account list.
- The forwarder verifies all DON signatures and re-computes the account hash over the full account list.
- The forwarder supplies
forwarderState+forwarderAuthorityand CPIs into your program'son_reportinstruction 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
- Generating Solana Bindings: IDL layout, generator output, and what
writeReportFrom...does - Solana Client SDK Reference: Full API reference for
SolanaClientand helpers - Solana Read & Write Capabilities: Architecture and account layout reference