Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
60 changes: 60 additions & 0 deletions contracts/capabilities/fakes/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Solana chain fakes

In-process implementation of the Solana chain capability used by cre-cli's
`cre workflow simulate`.

Reads (`GetAccountInfoWithOpts`, `GetBalance`, `GetSlotHeight`) proxy to the
configured RPC. Writes are the interesting part.

## How `WriteReport` works

Real DON writes go through the keystone-forwarder program, which verifies
`f+1` DON signatures against on-chain config. A local simulation cannot
produce those signatures, so the fake writes through a **mock forwarder**
instead — a permissionless program with the same instruction shape and CPI
behavior that skips signature verification (see
`programs/mock-forwarder`). The mock forwarder's program id and state account
come from the simulator config (cre-cli hardcodes per-chain defaults; see
`cre workflow supported-chains`).

SDK-generated bindings (Go and TypeScript) build reports for **production**:

- `remainingAccounts` layout: index 0 = forwarder state, index 1 = forwarder
authority PDA, index 2+ = receiver-specific accounts — taken from the
workflow config, i.e. the _real_ keystone forwarder;
- the report embeds `account_hash = sha256(concat(remainingAccount pubkeys))`,
which the forwarder recomputes on-chain over
`[state, authority, ...remaining]` and rejects on mismatch
(`Custom:6002 InvalidAccountHash`).

Since the fake substitutes the mock forwarder's state and authority, the
workflow-computed hash would never match. The fake therefore:

1. strips remaining accounts 0–1 (mirroring the real transmitter's
`forwarder_client.go`, which maps them onto named instruction accounts);
2. **rewrites `account_hash` in place** over the list the mock forwarder will
actually see: `[mock state, mock authority, ...receiver accounts]`
(`patchReportAccountHash`). This is safe precisely because the mock
forwarder verifies no DON signatures, and the transaction signature is
produced by the fake's transmitter key afterwards.

Net effect: workflows keep their production config — no simulation-specific
forwarder addresses, no binding changes. An `info` log is emitted whenever the
hash is rewritten.

## What workflow/receiver authors still need

The fake cannot influence the **receiver program's own caller validation**.
A receiver following the keystone pattern verifies that the CPI comes from
its trusted forwarder (state-account owner + authority PDA derived under a
stored or compiled-in forwarder program id). For simulation to reach
`on_report` successfully, that trust anchor must accept the mock forwarder —
e.g. initialize the receiver's state once against the mock forwarder program
id on devnet. This mirrors EVM, where receivers are deployed with the
documented (mock) forwarder address as a constructor argument.

## Modes

`dryRunWrites=true` routes through `SimulateTransaction` (no fees, no
signature verification); otherwise the transaction is sent and confirmed on
the live cluster and a real signature is returned.
69 changes: 54 additions & 15 deletions contracts/capabilities/fakes/solana_chain.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"github.com/gagliardetto/solana-go/rpc"

commonCap "github.com/smartcontractkit/chainlink-common/pkg/capabilities"
ocr3types "github.com/smartcontractkit/chainlink-common/pkg/capabilities/consensus/ocr3/types"
caperrors "github.com/smartcontractkit/chainlink-common/pkg/capabilities/errors"
solcap "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/chain-capabilities/solana"
solanaserver "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/chain-capabilities/solana/server"
Expand All @@ -31,6 +32,11 @@ const (
maxOracles = 16
reportContextLen = 96
signatureLen = 65

// mock_forwarder parses ForwarderReport at raw_report[METADATA_LENGTH..]
// (programs/mock-forwarder/src/internal.rs); account_hash, a sha256 digest,
// is its first field.
reportMetadataLen = ocr3types.MetadataLen // 109, same constant as the METADATA_LENGTH
)

const simUnimplementedMsg = "not implemented in cre-cli simulate; raise an issue if your workflow needs this read"
Expand Down Expand Up @@ -267,6 +273,53 @@ func (fc *FakeSolanaChain) WriteReport(
return nil, caperrors.NewPublicSystemError(fmt.Errorf("derive forwarder authority: %w", err), caperrors.Internal)
}

// Collect the receiver CPI accounts from the workflow-supplied remaining
// accounts.
//
// SDK bindings follow the keystone-forwarder account layout: index 0 is the
// forwarder state account, index 1 the forwarder authority PDA, index 2+ the
// receiver-specific accounts. The real transmitter (forwarder_client.go) maps
// indices 0-1 onto the report instruction's named accounts and forwards only
// 2+ as remaining accounts; mock_forwarder then rebuilds the hashed list as
// [state, authority, ...remaining].
remaining := input.RemainingAccounts
if len(remaining) >= 2 {
remaining = remaining[2:]
}
receiverAccounts := make([]*solana.AccountMeta, 0, len(remaining))
for _, acc := range remaining {
if acc == nil {
continue
}
pk, perr := pubkeyFromBytes(acc.GetPublicKey())
if perr != nil {
return nil, caperrors.NewPublicUserError(fmt.Errorf("remaining account: %w", perr), caperrors.InvalidArgument)
}
receiverAccounts = append(receiverAccounts, &solana.AccountMeta{
PublicKey: pk,
IsWritable: acc.IsWritable,
})
}

// The workflow computed the report's account hash over ITS configured
// forwarder accounts (normally the real keystone forwarder), but the
// simulator always writes through the mock forwarder, whose on-chain hash
// check would reject the report (Custom:6002 InvalidAccountHash). The mock
// forwarder does not verify DON signatures and the transaction is signed by
// our transmitter after this point, so the hash can be rewritten in place
// over the account list the mock forwarder will actually see.
changed, err := patchReportAccountHash(payload, len(input.Report.Sigs), fc.forwarderStateAccount, authority, receiverAccounts)
if err != nil {
return nil, caperrors.NewPublicUserError(fmt.Errorf("patch report account hash: %w", err), caperrors.InvalidArgument)
}
if changed {
fc.eng.Infow("rewrote report account hash for the simulator mock forwarder; on-chain writes outside `cre workflow simulate` use the forwarder accounts from the workflow config",
"mockForwarderProgram", fc.forwarderProgramID.String(),
"mockForwarderState", fc.forwarderStateAccount.String(),
"mockForwarderAuthority", authority.String(),
)
}

ix, err := mock_forwarder.NewReportInstruction(
payload,
fc.forwarderStateAccount,
Expand All @@ -278,22 +331,8 @@ func (fc *FakeSolanaChain) WriteReport(
if err != nil {
return nil, caperrors.NewPublicSystemError(fmt.Errorf("build report instruction: %w", err), caperrors.Internal)
}

// Append the workflow-supplied remaining accounts to the receiver CPI.
if generic, ok := ix.(*solana.GenericInstruction); ok {
for _, acc := range input.RemainingAccounts {
if acc == nil {
continue
}
pk, perr := pubkeyFromBytes(acc.PublicKey)
if perr != nil {
return nil, caperrors.NewPublicUserError(fmt.Errorf("remaining account: %w", perr), caperrors.InvalidArgument)
}
generic.AccountValues = append(generic.AccountValues, &solana.AccountMeta{
PublicKey: pk,
IsWritable: acc.IsWritable,
})
}
generic.AccountValues = append(generic.AccountValues, receiverAccounts...)
}

if fc.dryRunWrites {
Expand Down
37 changes: 37 additions & 0 deletions contracts/capabilities/fakes/solana_chain_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package fakes

import (
"context"
"crypto/sha256"
"strings"
"testing"

Expand Down Expand Up @@ -248,6 +249,42 @@ func TestBuildReportPayload(t *testing.T) {
})
}

func TestPatchReportAccountHash(t *testing.T) {
t.Parallel()
state := testForwarderStateAccount(t)
authority := solana.NewWallet().PublicKey()
receiverAcc := &solana.AccountMeta{PublicKey: solana.NewWallet().PublicKey()}

expected := sha256.Sum256(append(append(state.Bytes(), authority.Bytes()...), receiverAcc.PublicKey.Bytes()...))

t.Run("rewrites stale hash in place", func(t *testing.T) {
r := mkReportWith(2)
r.RawReport = make([]byte, reportMetadataLen+sha256.Size+8)
payload, err := buildReportPayload(r)
require.NoError(t, err)

changed, err := patchReportAccountHash(payload, len(r.Sigs), state, authority, []*solana.AccountMeta{receiverAcc})
require.NoError(t, err)
assert.True(t, changed)

start := 1 + len(r.Sigs)*signatureLen + reportMetadataLen
assert.Equal(t, expected[:], payload[start:start+sha256.Size])

// Second patch is a no-op: hash already matches.
changed, err = patchReportAccountHash(payload, len(r.Sigs), state, authority, []*solana.AccountMeta{receiverAcc})
require.NoError(t, err)
assert.False(t, changed)
})
t.Run("raw report too short for a ForwarderReport", func(t *testing.T) {
r := mkReport() // 110-byte raw report < METADATA_LENGTH + hash
payload, err := buildReportPayload(r)
require.NoError(t, err)
_, err = patchReportAccountHash(payload, 0, state, authority, nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "too short")
})
}

func TestDeriveForwarderAuthority_Stable(t *testing.T) {
t.Parallel()
prog := testForwarderProgramID(t)
Expand Down
33 changes: 33 additions & 0 deletions contracts/capabilities/fakes/solana_proto_helpers.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package fakes

import (
"bytes"
"crypto/sha256"
"fmt"

"github.com/gagliardetto/solana-go"
"github.com/gagliardetto/solana-go/rpc"

solcap "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/chain-capabilities/solana"
Expand Down Expand Up @@ -53,3 +56,33 @@ func buildReportPayload(report *sdk.ReportResponse) ([]byte, error) {
out = append(out, report.ReportContext...)
return out, nil
}

// patchReportAccountHash overwrites ForwarderReport.account_hash inside an
// assembled report payload with sha256 over the account list the mock
// forwarder rebuilds on-chain: [state, authority, ...receiverAccounts].
// The hash sits at raw_report[reportMetadataLen : reportMetadataLen+sha256.Size],
// and raw_report starts after len_sigs(1) + signatures(numSigs*65).
// Same digest the SDK bindings produce (cre-sdk-go
// capabilities/blockchain/solana/bindings CalculateAccountsHash) — keep in sync.
// Returns whether the hash actually differed from the workflow-computed one.
func patchReportAccountHash(payload []byte, numSigs int, state, authority solana.PublicKey, receiverAccounts []*solana.AccountMeta) (bool, error) {
start := 1 + numSigs*signatureLen + reportMetadataLen
end := start + sha256.Size
if end > len(payload)-reportContextLen {
return false, fmt.Errorf("report payload too short to carry a ForwarderReport account hash (len %d, need raw report >= %d)", len(payload), reportMetadataLen+sha256.Size)
}

h := sha256.New()
h.Write(state.Bytes())
h.Write(authority.Bytes())
for _, acc := range receiverAccounts {
h.Write(acc.PublicKey.Bytes())
}
sum := h.Sum(nil)

if bytes.Equal(payload[start:end], sum) {
return false, nil
}
copy(payload[start:end], sum)
return true, nil
}
Loading