Skip to content

Forwarder #1179

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 41 commits into from
Jun 5, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
4f3d2d8
initial design
augustbleeds Apr 10, 2025
14e7688
disable frozen-abi
augustbleeds Apr 10, 2025
cacc3ab
update with tests
augustbleeds Apr 18, 2025
1c6540d
clean tests
augustbleeds Apr 21, 2025
c7af7dd
clean up comments + add README
augustbleeds Apr 22, 2025
96669af
fix docs
augustbleeds Apr 22, 2025
e92341f
Update Anchor.toml
augustbleeds Apr 22, 2025
5c05440
format, fix underscores
augustbleeds Apr 24, 2025
b75388f
linting
augustbleeds Apr 24, 2025
708dd05
format
augustbleeds Apr 24, 2025
1e2950b
Merge branch 'develop' into augustus.NONEVM-1516.forwarder
augustbleeds Apr 24, 2025
3334a12
Fix Lint and Run All Tests
augustbleeds Apr 24, 2025
45e3cee
Resolve Naming Conflict
augustbleeds Apr 24, 2025
5cc9075
remove user passed in pda bump
augustbleeds May 2, 2025
d17ee7b
use explicit accounts in dummy receiver
augustbleeds May 2, 2025
2a56634
add check for authorized forwarder in dummy receiver
augustbleeds May 2, 2025
579ed0e
add forwarder authority verification in the actual anchor constraint
augustbleeds May 2, 2025
1bb2e38
tidy up tomls
augustbleeds May 2, 2025
9842523
init_if_needed + add requires for set_config
augustbleeds May 5, 2025
a058ce9
tidy up keystone
augustbleeds May 5, 2025
98ef3f4
fix documentation
augustbleeds May 5, 2025
ddf6fc0
split into files
augustbleeds May 5, 2025
b3766fe
remove unnecessary check
augustbleeds May 7, 2025
9872702
fix tests to have variable N and f
augustbleeds May 7, 2025
a3c9347
format
augustbleeds May 7, 2025
a1dec90
Merge branch 'develop' into augustus.NONEVM-1516.forwarder
augustbleeds May 7, 2025
6442d0e
Merge branch 'develop' into augustus.NONEVM-1516.forwarder
augustbleeds May 14, 2025
92be3de
Merge branch 'develop' into augustus.NONEVM-1516.forwarder
augustbleeds May 20, 2025
57dae86
Merge branch 'develop' into augustus.NONEVM-1516.forwarder
augustbleeds May 22, 2025
252c1f0
Merge branch 'develop' into augustus.NONEVM-1516.forwarder
augustbleeds May 22, 2025
53e625c
Merge branch 'develop' into augustus.NONEVM-1516.forwarder
augustbleeds May 23, 2025
520c659
Adding events to forwarder contract (#1251)
chray-zhang May 30, 2025
5be4032
Merge branch 'develop' into augustus.NONEVM-1516.forwarder
augustbleeds May 30, 2025
5d333be
Merge branch 'develop' into augustus.NONEVM-1516.forwarder
augustbleeds May 30, 2025
1a9ad5f
fix async rejections + infinite listening for events
augustbleeds Jun 4, 2025
e6cb88b
format
augustbleeds Jun 4, 2025
8c32207
await events in middle not end
augustbleeds Jun 4, 2025
895411e
increase timeout
augustbleeds Jun 4, 2025
bd05537
set timeout
augustbleeds Jun 4, 2025
df1f57c
typo
augustbleeds Jun 4, 2025
571946a
skip transfer events
augustbleeds Jun 4, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/rust.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,51 +34,52 @@
runner-os: ${{ runner.os }}

rust_run_anchor_tests:
timeout-minutes: 15
name: Rust Run Anchor Tests
runs-on: ubuntu-latest-8cores-32GB
needs: [get_projectserum_version, build_wrapped_anchor_image]
steps:
- uses: actions/checkout@44c2b7a8a4ea60a981eaca3cf939b5f4305c123b # v4.1.5
- name: Cache cargo target dir
uses: actions/cache@v4
with:
path: contracts/target
key: ${{ runner.os }}-v2-cargo-build-target-${{ hashFiles('**/Cargo.lock') }}
- name: Cache hello-world target dir
uses: actions/cache@v4
with:
path: contracts/examples/hello-world/target
key: ${{ runner.os }}-v2-cargo-build-target-hello-world-${{ hashFiles('**/Cargo.lock') }}
- name: cache docker build image
id: cache-image
uses: actions/cache@v4
with:
path: contracts/docker-build.tar
key: ${{ runner.os }}-docker-pnpm-build-${{ needs.get_projectserum_version.outputs.projectserum_version }}-${{ hashFiles('**/Cargo.lock') }}
- name: load cached image
run: |
docker load --input docker-build.tar
- name: run tests
run: |
docker run -v "$(pwd)/../":/repo chainlink-solana:build bash -c "\
set -eoux pipefail &&\
RUSTUP_HOME=\"/root/.rustup\" &&\
FORCE_COLOR=1 &&\
cd /repo/contracts &&\
solana-keygen new -o id.json --no-bip39-passphrase &&\
cd /repo/ts &&\
pnpm install --frozen-lockfile &&\
pnpm build &&\
cd /repo/contracts &&\
pnpm install --frozen-lockfile &&\
anchor test &&\
chmod -R 755 ./target &&\
cd /repo/contracts/examples/hello-world &&\
pnpm install --frozen-lockfile &&\
anchor test &&\
chmod -R 755 ./target"

rust_lint:

Check warning

Code scanning / CodeQL

Workflow does not contain permissions Medium

Actions job or workflow does not limit the permissions of the GITHUB_TOKEN. Consider setting an explicit permissions block, using the following as a minimal starting point: {contents: read}
name: Rust Lint
runs-on: ubuntu-latest
needs: [get_projectserum_version, build_wrapped_anchor_image]
Expand Down
3 changes: 3 additions & 0 deletions contracts/Anchor.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,12 @@ test = "pnpm run test"
# TODO: add pubkeys

[programs.localnet]
# note: replace underscores "_" with dashes "-" for "anchor test" to use declared program id
access_controller = "9xi644bRR8birboDGdTiwBq3C7VEeR7VuamRYYXCubUW"
contract-reader-interface = "6AfuXF6HapDUhQfE4nQG9C1SGtA1YjP3icaJyRfU4RyE"
contract-reader-interface-secondary = "9SFyk8NmGYh5D612mJwUYhguCRY9cFgaS2vksrigepjf"
dummy_receiver = "5z38tFCAmcPJb1DXUHSoKQhR8qQ8o9aNZ8rZFWe6gH4L"
keystone_forwarder = "whV7Q5pi17hPPyaPksToDw1nMx6Lh8qmNWKFaLRQ4wz"
log-read-test = "J1zQwrBNBngz26jRPNWsUSZMHJwBwpkoDitXRV95LdK4"
ocr_2 = "cjg3oHmg9uuPsP8D6g29NWvhySJkdYdAo9D25PRbKXJ" # need to rename the idl to satisfy anchor.js...
store = "HEvSKofvBgfaexv23kMabbYqxasxU3mQ4ibBMEmJWHny"
16 changes: 16 additions & 0 deletions contracts/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

20 changes: 20 additions & 0 deletions contracts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,26 @@ Run anchor tests (automatically tests against a local node).
anchor test
```

### Using nix shell to test

1. In `shell.nix` comment out:

```
# (rust-bin.stable.latest.default.override { extensions = ["rust-src"]; })
# lld_11
```

to use local version of `rustup` and `cargo`

2. Ensure `ts/` is built with `/ts && pnpm build`

3. As of 05/21/2025 works with:

- `cargo 1.79.0 (ffa9cf99a 2024-06-03)`
- `rustc 1.79.0 (129f3b996 2024-06-10)`

4. `anchor build && anchor test`

### `anchor-go` bindings generation

Install `https://github.com/gagliardetto/anchor-go`
Expand Down
2 changes: 1 addition & 1 deletion contracts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,6 @@
},
"scripts": {
"format": "pnpm prettier --write .",
"test": "pnpm up @chainlink/solana-sdk && ts-mocha -t 1000000 tests/*.ts"
"test": "pnpm up @chainlink/solana-sdk && ts-mocha -t 1000000 tests/*.spec.ts"
}
}
20 changes: 20 additions & 0 deletions contracts/programs/dummy-receiver/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
[package]
name = "dummy-receiver"
version = "0.1.0"
description = "Created with Anchor"
edition = "2021"

[lib]
crate-type = ["cdylib", "lib"]
name = "dummy_receiver"

[features]
no-entrypoint = []
no-idl = []
no-log-ix-name = []
cpi = ["no-entrypoint"]
default = []

[dependencies]
anchor-lang = "0.29.0"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

have we considered a 0.31.1 upgrade for the repo?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't... i'll look into it though. If we did 0.31.1 repo wouldn't we need another audit for the old contracts? and then bump those up on mainnet?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

EDIT: oh I assumed that anchor versions were tied to solana versions, but it seems they are mix and match. however i think an anchor version change could still potentially change change deployed bytecode.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah it would potentially require a lot more work - i don't know if it's worth doing it now, but just pointing out that it might be good to have

especially since the team is responsible for solana DF contracts now

keystone-forwarder = { version = "0.1.0", path = "../keystone-forwarder", default-features = false, features = ["cpi"] }
2 changes: 2 additions & 0 deletions contracts/programs/dummy-receiver/Xargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[target.bpfel-unknown-unknown.dependencies.std]
features = []
101 changes: 101 additions & 0 deletions contracts/programs/dummy-receiver/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
use anchor_lang::prelude::*;
use keystone_forwarder::ForwarderState;
use keystone_forwarder::ID as FORWARDER_ID;

declare_id!("5z38tFCAmcPJb1DXUHSoKQhR8qQ8o9aNZ8rZFWe6gH4L");

// THIS IS UN-AUDITED CODE USED FOR TESTING PURPOSES ONLY
// DO NOT USE THIS CODE IN PRODUCTION.

#[program]
pub mod dummy_receiver {
use super::*;

pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
ctx.accounts.report_state.forwarder_authority = ctx.accounts.forwarder_authority.key();
Ok(())
}

pub fn on_report<'info>(
ctx: Context<'_, '_, 'info, 'info, OnReport<'info>>,
metadata: Vec<u8>,
report: Vec<u8>,
) -> Result<()> {
// verify
// 1. forwarder authority signer belongs to (is a PDA derived from) forwarder state (done in the anchor constraint!)
// 2. forwarder authority signer is authorized by this program
// 3. report metadata (not done in this dummy example)

// 2
require!(
ctx.accounts.forwarder_authority.key() == ctx.accounts.report_state.forwarder_authority,
AuthError::Unauthorized
);

// in a production setting you'd also want to verify the metadata too...

ctx.accounts.report_state.report = report;
ctx.accounts.report_state.metadata = metadata;

// note: alternative account implementation could pass as ctx.remaining_accounts
// however that requires more work

// let account_info = &ctx.remaining_accounts[0];
// let mut latest_report: Account<'info, LatestReport> = Account::try_from(account_info)?;
// latest_report.metadata = metadata;
// latest_report.report = report;

// // includes anchor discriminator by default
// latest_report.try_serialize(&mut &mut account_info.data.borrow_mut()[..])?;

Ok(())
}
}

#[error_code]
pub enum AuthError {
#[msg("The signer is unauthorized")]
Unauthorized,
}

#[account]
#[derive(Default)]
pub struct LatestReport {
pub metadata: Vec<u8>,
pub report: Vec<u8>,
pub forwarder_authority: Pubkey,
}

#[derive(Accounts)]
pub struct Initialize<'info> {
#[account(
init,
payer = signer,
space = 8 + 4 + 4 + 65 + 32 // [64 (metadata) + 1 (report)] = 65
)]
pub report_state: Account<'info, LatestReport>,

#[account(mut)]
pub signer: Signer<'info>,

/// CHECK: this is the expected signer of "on_report"
#[account()]
// #[account(address = report_state.key() @ AuthError::Unauthorized)]
pub forwarder_authority: UncheckedAccount<'info>,

pub system_program: Program<'info, System>,
}

#[derive(Accounts)]
pub struct OnReport<'info> {
#[account(owner = FORWARDER_ID)]
pub state: Account<'info, ForwarderState>,
Comment on lines +91 to +92
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since you can initialize multiple forwarders with the same program, this receiver would allow inputs from any forwarder (an attacker could create a forwarder)

potential solution is to create an allow list of forwarder states

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Although an attacker could potentially create a forwarder, it's up to the receiver program to verify that the instance is allowed. I guess I forgot to do that here so I'll need to add that. But it'd be a hardcoded thing exactly like you mention. The data feeds cache on EVM for example has a list of allowed callers. Good call out

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note that a receiver might want to support multiple forwarders writing to it - so an array (with realloc) might be a good solution here (assuming it can be a bounded set of forwarders, if not then PDAs might be necessary - CCIP ran into something similar here on the token pools)


// note the forwarder authority is a PDA signer
#[account(seeds = [b"forwarder", state.key().as_ref()], bump = state.authority_nonce, seeds::program = FORWARDER_ID)]
pub forwarder_authority: Signer<'info>,

#[account(mut)]
pub report_state: Account<'info, LatestReport>,
// remaining accounts may be passed in
}
20 changes: 20 additions & 0 deletions contracts/programs/keystone-forwarder/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
[package]
name = "keystone-forwarder"
version = "0.1.0"
description = "Created with Anchor"
edition = "2021"

[lib]
crate-type = ["cdylib", "lib"]
name = "keystone_forwarder"

[features]
no-entrypoint = []
no-idl = []
no-log-ix-name = []
cpi = ["no-entrypoint"]
default = []

[dependencies]
anchor-lang = { version = "0.29.0", features = ["init-if-needed"] }
solana-program = { version = "1.17.25" }
2 changes: 2 additions & 0 deletions contracts/programs/keystone-forwarder/Xargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[target.bpfel-unknown-unknown.dependencies.std]
features = []
13 changes: 13 additions & 0 deletions contracts/programs/keystone-forwarder/src/common.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
pub const STATE_VERSION: u8 = 1;

pub const ANCHOR_DISCRIMINATOR: usize = 8;

pub const REPORT_CONTEXT_LEN: usize = 96;

// our don size is directly limited by the transaction size during on_report's signature verification
pub const MAX_ORACLES: usize = 17;

pub const SIGNATURE_LEN: usize = 65;

pub const FORWARDER_METADATA_LENGTH: usize = 45;
pub const METADATA_LENGTH: usize = 109;
137 changes: 137 additions & 0 deletions contracts/programs/keystone-forwarder/src/context.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
use crate::common::ANCHOR_DISCRIMINATOR;
use crate::error::AuthError;
use crate::state::{ExecutionState, ForwarderState, OraclesConfig};
use crate::utils::{extract_config_id, extract_raw_report, extract_transmission_id, get_config_id};
use anchor_lang::prelude::*;

#[derive(Accounts)]
pub struct Initialize<'info> {
// the account is not a PDA but it is initialized by the program
#[account(
init,
payer = owner,
space = ANCHOR_DISCRIMINATOR + ForwarderState::INIT_SPACE
)]
pub state: Account<'info, ForwarderState>,
#[account(mut)]
pub owner: Signer<'info>,

pub system_program: Program<'info, System>,
}

#[derive(Accounts)]
pub struct TransferOwnership<'info> {
#[account(mut)]
pub state: Account<'info, ForwarderState>,

#[account(address = state.owner @ AuthError::Unauthorized)]
pub current_owner: Signer<'info>,
}

#[derive(Accounts)]
pub struct AcceptOwnership<'info> {
#[account(mut)]
pub state: Account<'info, ForwarderState>,

#[account(address = state.proposed_owner @ AuthError::Unauthorized)]
pub proposed_owner: Signer<'info>,
}

#[derive(Accounts)]
#[instruction(don_id: u32, config_version: u32, f: u8, signer_addresses: Vec<[u8; 20]>)]
pub struct InitOraclesConfig<'info> {
pub state: Account<'info, ForwarderState>,

#[account(
init,
payer = owner,
seeds = [b"config", state.key().as_ref(), &get_config_id(don_id, config_version).to_be_bytes()],
bump,
space = ANCHOR_DISCRIMINATOR + OraclesConfig::space_with_signers(signer_addresses.len())
)]
pub oracles_config: Account<'info, OraclesConfig>,

#[account(mut, address = state.owner @ AuthError::Unauthorized)]
pub owner: Signer<'info>, // must be the same owner as the one in the state account

pub system_program: Program<'info, System>,
}

#[derive(Accounts)]
#[instruction(don_id: u32, config_version: u32, f: u8, signer_addresses: Vec<[u8; 20]>)]
pub struct UpdateOraclesConfig<'info> {
pub state: Account<'info, ForwarderState>,

#[account(
mut,
seeds = [b"config", state.key().as_ref(), &get_config_id(don_id, config_version).to_be_bytes()],
bump,
realloc = ANCHOR_DISCRIMINATOR + OraclesConfig::space_with_signers(signer_addresses.len()),
realloc::payer = owner,
realloc::zero = true
)]
pub oracles_config: Account<'info, OraclesConfig>,

#[account(mut, address = state.owner @ AuthError::Unauthorized)]
pub owner: Signer<'info>,

pub system_program: Program<'info, System>,
}

#[derive(Accounts)]
#[instruction(don_id: u32, config_version: u32)]
pub struct CloseOraclesConfig<'info> {
pub state: Account<'info, ForwarderState>,

#[account(
mut,
seeds = [b"config", state.key().as_ref(), &get_config_id(don_id, config_version).to_be_bytes()],
bump,
close = owner
)]
pub oracles_config: Account<'info, OraclesConfig>,

#[account(mut, address = state.owner @ AuthError::Unauthorized)]
pub owner: Signer<'info>, // must be the same owner as the one in the state account
}

#[derive(Accounts)]
#[instruction(data: Vec<u8>)]
pub struct Report<'info> {
pub state: Account<'info, ForwarderState>,

#[account(
mut,
seeds = [b"config", state.key().as_ref(), &extract_config_id(extract_raw_report(&data))],
bump
)]
pub oracles_config: Account<'info, OraclesConfig>,

#[account(mut)]
pub transmitter: Signer<'info>,

/// CHECK: This is a PDA
#[account(seeds = [b"forwarder", state.key().as_ref()], bump = state.authority_nonce)]
pub forwarder_authority: UncheckedAccount<'info>,

// it is dependent on the state.key(), a predetermined bump, workflow execution id, config_id, report_id
#[account(
init_if_needed,
payer = transmitter,
space = ANCHOR_DISCRIMINATOR + ExecutionState::INIT_SPACE,
seeds = [
b"execution_state",
state.key().as_ref(),
&extract_transmission_id(extract_raw_report(&data), receiver_program.key)
],
bump
)]
pub execution_state: Account<'info, ExecutionState>,

#[account(executable)]
/// CHECK: We don't use Program<> here since it can be any program, "executable" is enough
pub receiver_program: UncheckedAccount<'info>,

pub system_program: Program<'info, System>,
// remaining accounts passed to receiver
}
Loading
Loading