Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
0ea5199
feat: add WithExecutePayers ctx function for setting solana accounts …
ecPablo Jul 10, 2026
7d3ab9f
fix: linting errors
ecPablo Jul 10, 2026
7d2f102
fix: linting errors
ecPablo Jul 10, 2026
d9103a6
fix: linting errors
ecPablo Jul 10, 2026
8e3e1ab
fix: linting errors
ecPablo Jul 10, 2026
8d11e70
Potential fix for pull request finding
ecPablo Jul 10, 2026
0f00101
Potential fix for pull request finding
ecPablo Jul 10, 2026
af05ac5
fix: linting errors
ecPablo Jul 10, 2026
316e5c0
feat: update approach to get execute payers from chain metadata inste…
ecPablo Jul 11, 2026
b4223f9
feat: update docs
ecPablo Jul 13, 2026
f1d61e2
fixc: increase unit test coverage
ecPablo Jul 13, 2026
505017e
Potential fix for pull request finding
ecPablo Jul 13, 2026
6a93d97
Potential fix for pull request finding
ecPablo Jul 13, 2026
4ab690e
fix: copilot comments
ecPablo Jul 13, 2026
c26f14e
fix: copilot comments
ecPablo Jul 13, 2026
0391a1a
Potential fix for pull request finding
ecPablo Jul 14, 2026
ce50685
Potential fix for pull request finding
ecPablo Jul 14, 2026
6b1f4f5
fix: address review comments
ecPablo Jul 14, 2026
d30e0ef
fix: address review comments
ecPablo Jul 14, 2026
0060afb
Potential fix for pull request finding
ecPablo Jul 14, 2026
c8c1d88
Potential fix for pull request finding
ecPablo Jul 14, 2026
031122f
fix: address review comments
ecPablo Jul 14, 2026
31f55cd
fix: unit tests
ecPablo Jul 14, 2026
660b768
Merge branch 'main' into ecpablo/add-execute-payers-solana
ecPablo Jul 14, 2026
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
41 changes: 41 additions & 0 deletions docs/docs/key-concepts/chain-metadata.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,44 @@ The starting operation count, typically used for parallel signing processes.

**mcmAddress** string<br/>
The MCM contract address that will process this proposal on the respective chain.

---

**additionalFields** object _optional_<br/>
Chain-family-specific fields encoded as JSON. Structure depends on the chain family (see below).

### Solana Additional Fields

Solana chain metadata uses `additionalFields` for the Timelock role access-controller accounts and, for bypass proposals, the execute fee payer.

| Field | Required | When used |
| --- | --- | --- |
| `proposerRoleAccessController` | yes | schedule conversion |
| `cancellerRoleAccessController` | yes | cancel conversion |
| `bypasserRoleAccessController` | yes | bypass conversion |
| `executePayer` | no | bypass only — account that pays (and therefore signs) the outer MCM execute transaction |

Example Solana `chainMetadata` entry:

```json
"5013781088424303360": {
"startingOpCount": 0,
"mcmAddress": "<programId>.<seed>",
"additionalFields": {
Comment thread
ecPablo marked this conversation as resolved.
"proposerRoleAccessController": "...",
"cancellerRoleAccessController": "...",
"bypasserRoleAccessController": "...",
"executePayer": "<base58 execute-payer pubkey>"
}
}
```

#### `executePayer`

When the execute payer also appears in a bypass operation's `remaining_accounts` (for example as a BPF upgrade spill / close recipient), the Solana runtime always presents the fee payer as `IsSigner=true` at execution time. Off-chain conversion otherwise defaults remaining accounts to non-signer. Without recording `executePayer` in chain metadata, the Merkle leaf hashed off-chain does not match on-chain proof verification and execution fails with `ProofCannotBeVerified`.

**When to set it:** Solana **bypass** proposals where the fee-payer pubkey is listed as a writable remaining account. Omit for schedule/cancel; the converter ignores `executePayer` for non-bypass actions.

**Go helper:** `AdditionalFieldsMetadata.WithExecutePayer(pk)` in [`sdk/solana/chain_metadata.go`](https://github.com/smartcontractkit/mcms/blob/main/sdk/solana/chain_metadata.go).

**Reference scenario:** [`e2e/tests/solana/timelock_bypass_payer_collision.go`](https://github.com/smartcontractkit/mcms/blob/main/e2e/tests/solana/timelock_bypass_payer_collision.go).
2 changes: 1 addition & 1 deletion docs/docs/key-concepts/timelock-proposal.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ A Unix timestamp that specifies the proposal's expiration. If the proposal is no
Specifies the high-level action for the proposal. Can be one of:
- `schedule`: Sets up transactions to execute after a delay.
- `cancel`: Cancels previously scheduled transactions.
- `bypass`: Directly executes transactions, skipping the timelock.
- `bypass`: Directly executes transactions, skipping the timelock. For Solana bypass proposals, if the execute fee payer also appears in a batch op's remaining accounts, set `executePayer` in that chain's `additionalFields` so Merkle proof verification succeeds. See [Chain Metadata — Solana Additional Fields](./chain-metadata.md#solana-additional-fields).

---

Expand Down
3 changes: 3 additions & 0 deletions docs/docs/usage/building-proposals.md
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,9 @@ builder.AddOperation(types.Operation{ChainSelector: selector, Transaction: tx})

```

When building Solana **timelock bypass** proposals programmatically, if the execute fee payer appears in remaining accounts, set `executePayer` on that chain's metadata (for example via `AdditionalFieldsMetadata.WithExecutePayer`) so conversion hashes the Merkle leaf with `IsSigner=true`. See [Chain Metadata — Solana Additional Fields](../key-concepts/chain-metadata.md#solana-additional-fields).


### Aptos Operations

Use the `aptos.NewTransaction` helper to build an Aptos specific transaction.
Expand Down
243 changes: 243 additions & 0 deletions e2e/tests/solana/timelock_bypass_payer_collision.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
//go:build e2e

package solanae2e

import (
"context"
"encoding/json"
"time"

"github.com/ethereum/go-ethereum/common"

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

"github.com/smartcontractkit/chainlink-ccip/chains/solana/gobindings/v0_1_1/timelock"

"github.com/smartcontractkit/mcms"
e2eutils "github.com/smartcontractkit/mcms/e2e/utils/solana"
"github.com/smartcontractkit/mcms/sdk"
solanasdk "github.com/smartcontractkit/mcms/sdk/solana"
"github.com/smartcontractkit/mcms/types"
)

var (
testPDASeedBypassPayerWith = [32]byte{'t', 'e', 's', 't', '-', 'b', 'y', 'p', 'a', 's', 's', '-', 'p', 'a', 'y', 'e', 'r', '-', 'w'}
testPDASeedBypassPayerWithout = [32]byte{'t', 'e', 's', 't', '-', 'b', 'y', 'p', 'a', 's', 's', '-', 'p', 'a', 'y', 'e', 'r', '-', 'n'}
)

const bypassPayerTransferLamports = 1_000_000 // 0.001 SOL

// TestBypassExecutePayerInRemainingAccounts covers the Solana bypass failure
// where the execute payer (the deployer key) also appears in the
// BypasserExecuteBatch op's remaining_accounts.
//
// Real-world shape: a BPF-loader `upgrade` instruction lists the deployer as the
// spill/close recipient — a writable, non-signer account. Off-chain the Solana
// converter forces every remaining account to IsSigner=false before computing
// the Merkle root, so the deployer is hashed with IsSigner=false. At execution
// time the same deployer key is the outer transaction fee payer, so the Solana
// runtime presents it to the MCM program as IsSigner=true. The MCM program
// rebuilds the Merkle leaf from the runtime account infos, hashes IsSigner=true,
// and the one-bit mismatch invalidates the proof -> ProofCannotBeVerified.
//
// This test uses a system.Transfer whose recipient is the deployer/executor
// wallet to reproduce the identical one-bit collision without deploying an
// upgradeable program + buffer.
//
// - "with execute payer in metadata": the proposal's Solana chain metadata records
// the executor as executePayer, so the converter marks that account IsSigner=true
// before the root is computed and the bypass executes cleanly.
// - "without execute payer in metadata": the same proposal converted without the
// field still fails with ProofCannotBeVerified, documenting the bug and guarding
// against the fix silently becoming a no-op.
func (s *TestSuite) TestBypassExecutePayerInRemainingAccounts() {
s.Run("with execute payer in metadata: bypass succeeds", func() {
s.runBypassPayerCollision(testPDASeedBypassPayerWith, true)
})
s.Run("without execute payer in metadata: proof fails", func() {
s.runBypassPayerCollision(testPDASeedBypassPayerWithout, false)
})
}

// runBypassPayerCollision drives the full bypass flow (convert -> set config ->
// sign -> set root -> execute) for a batch whose inner instruction sends
// lamports to the executor wallet. When setExecutePayerInMetadata is true, the
// executor is recorded in the proposal's Solana chain metadata so the converter
// marks it as a signer and the bypass execute succeeds; otherwise the final op
// fails with ProofCannotBeVerified.
func (s *TestSuite) runBypassPayerCollision(seed [32]byte, setExecutePayerInMetadata bool) {
// --- arrange ---
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
s.T().Cleanup(cancel)

// wallet is the deployer key: MCM executor / outer transaction fee payer.
wallet, err := solana.PrivateKeyFromBase58(privateKey)
s.Require().NoError(err)

s.SetupMCM(seed)
s.SetupTimelock(seed, 1*time.Second)

mcmSignerPDA, err := solanasdk.FindSignerPDA(s.MCMProgramID, seed)
s.Require().NoError(err)
// The MCM signer PDA drives the bypass instructions, so it must hold the
// bypasser role.
s.AssignRoleToAccounts(ctx, seed, wallet, []solana.PublicKey{mcmSignerPDA}, timelock.Bypasser_Role)

timelockSignerPDA, err := solanasdk.FindTimelockSignerPDA(s.TimelockProgramID, seed)
s.Require().NoError(err)

// Fund the timelock signer PDA (transfer source, signs via CPI) and the mcm
// signer PDA.
e2eutils.FundAccounts(s.T(), []solana.PublicKey{mcmSignerPDA, timelockSignerPDA}, 1, s.SolanaClient)

mcmAddress := solanasdk.ContractAddress(s.MCMProgramID, seed)
timelockAddress := solanasdk.ContractAddress(s.TimelockProgramID, seed)

// --- inner "spill-like" instruction ---
// Transfer lamports from the timelock signer PDA to the deployer wallet.
// The recipient (wallet) is a writable, non-signer account: exactly the
// role a BPF upgrade spill account plays in production.
transferIx, err := system.NewTransferInstruction(bypassPayerTransferLamports, timelockSignerPDA, wallet.PublicKey()).
ValidateAndBuild()
s.Require().NoError(err)

transferTx, err := solanasdk.NewTransactionFromInstruction(transferIx, "System",
[]string{"bypass-payer-collision"})
s.Require().NoError(err)

batchOp := types.BatchOperation{
ChainSelector: s.ChainSelector,
Transactions: []types.Transaction{transferTx},
}

// --- chain metadata ---
opCount, err := solanasdk.NewInspector(s.SolanaClient).GetOpCount(ctx, mcmAddress)
s.Require().NoError(err)
metadata, err := solanasdk.NewChainMetadata(opCount, s.MCMProgramID, seed,
s.Roles[timelock.Proposer_Role].AccessController.PublicKey(),
s.Roles[timelock.Canceller_Role].AccessController.PublicKey(),
s.Roles[timelock.Bypasser_Role].AccessController.PublicKey())
s.Require().NoError(err)
if setExecutePayerInMetadata {
var additionalFields solanasdk.AdditionalFieldsMetadata
s.Require().NoError(json.Unmarshal(metadata.AdditionalFields, &additionalFields))
additionalFields = additionalFields.WithExecutePayer(wallet.PublicKey())
metadata.AdditionalFields, err = json.Marshal(additionalFields)
s.Require().NoError(err)
}

// --- bypass proposal ---
timelockProposal, err := mcms.NewTimelockProposalBuilder().
SetVersion("v1").
SetValidUntil(2051222400). // 2035-01-01T00:00:00 UTC
SetDescription("bypass proposal: executor payer appears in remaining_accounts").
Comment thread
Copilot marked this conversation as resolved.
SetOverridePreviousRoot(true).
SetDelay(types.NewDuration(1*time.Second)).
SetAction(types.TimelockActionBypass).
AddTimelockAddress(s.ChainSelector, timelockAddress).
AddChainMetadata(s.ChainSelector, metadata).
AddOperation(batchOp).
Build()
s.Require().NoError(err)

converters := map[types.ChainSelector]sdk.TimelockConverter{
s.ChainSelector: solanasdk.TimelockConverter{},
}

mcmsProposal, _, err := timelockProposal.Convert(ctx, converters)
s.Require().NoError(err)

// The executor wallet lands in the final BypasserExecuteBatch op as a
// writable remaining account. Its IsSigner flag must reflect whether the
// execute payer was recorded in chain metadata.
s.assertExecutorSignerBit(mcmsProposal, wallet.PublicKey(), setExecutePayerInMetadata)

// --- set config + sign + set root ---
signerEVMAccount := NewEVMTestAccount(s.T())
mcmConfig := types.Config{Quorum: 1, Signers: []common.Address{signerEVMAccount.Address}}
configurer := solanasdk.NewConfigurer(s.SolanaClient, wallet, s.ChainSelector)
_, err = configurer.SetConfig(ctx, mcmAddress, &mcmConfig, true)
s.Require().NoError(err)

inspectors := map[types.ChainSelector]sdk.Inspector{s.ChainSelector: solanasdk.NewInspector(s.SolanaClient)}
signable, err := mcms.NewSignable(&mcmsProposal, inspectors)
s.Require().NoError(err)
_, err = signable.SignAndAppend(mcms.NewPrivateKeySigner(signerEVMAccount.PrivateKey))
s.Require().NoError(err)

encoders, err := mcmsProposal.GetEncoders() //nolint:contextcheck,nolintlint //OPT-400
s.Require().NoError(err)
encoder := encoders[s.ChainSelector].(*solanasdk.Encoder)
executors := map[types.ChainSelector]sdk.Executor{
s.ChainSelector: solanasdk.NewExecutor(encoder, s.SolanaClient, wallet),
}
executable, err := mcms.NewExecutable(&mcmsProposal, executors) //nolint:contextcheck,nolintlint //OPT-400
s.Require().NoError(err)

_, err = executable.SetRoot(ctx, s.ChainSelector)
s.Require().NoError(err)

// --- act + assert ---
// The set-up ops (init/append/finalize bypasser operation) never include the
// executor key, so their proofs verify regardless. Only the final
// BypasserExecuteBatch op carries the executor key in its remaining accounts.
lastOp := len(mcmsProposal.Operations) - 1
s.Require().Positive(lastOp, "expected multiple bypass ops")

balanceBefore := s.lamports(ctx, timelockSignerPDA)

// Execute setup ops (init/append/finalize); they don't carry the executor key
// in their accounts so their proofs verify regardless of execute payer metadata.
for i := range lastOp {
_, err = executable.Execute(ctx, i)
s.Require().NoError(err, "unexpected failure on setup op %d", i)
}
Comment thread
Copilot marked this conversation as resolved.

// Execute the final BypasserExecuteBatch op — the one whose remaining_accounts
// include the executor wallet, causing the signer-bit collision.
_, execErr := executable.Execute(ctx, lastOp)
if setExecutePayerInMetadata {
s.Require().NoError(execErr, "BypasserExecuteBatch should succeed once the execute payer is a signer")
} else {
s.Require().Error(execErr, "expected BypasserExecuteBatch to fail due to execute-payer signer collision")
s.Require().ErrorContains(execErr, "ProofCannotBeVerified")
}

if setExecutePayerInMetadata {
// The inner transfer actually moved lamports out of the timelock signer PDA.
balanceAfter := s.lamports(ctx, timelockSignerPDA)
s.Require().Equal(balanceBefore-bypassPayerTransferLamports, balanceAfter,
"timelock signer PDA should have sent exactly the transfer amount")
}
}

// assertExecutorSignerBit checks the executor key appears in the last converted
// op (BypasserExecuteBatch) as a writable remaining account with the expected
// IsSigner flag.
func (s *TestSuite) assertExecutorSignerBit(proposal mcms.Proposal, executor solana.PublicKey, wantSigner bool) {
s.Require().NotEmpty(proposal.Operations)
lastOp := proposal.Operations[len(proposal.Operations)-1]

var fields solanasdk.AdditionalFields
s.Require().NoError(json.Unmarshal(lastOp.Transaction.AdditionalFields, &fields))

found := false
for _, acc := range fields.Accounts {
if acc.PublicKey.Equals(executor) {
found = true
s.Require().Equal(wantSigner, acc.IsSigner, "executor IsSigner flag mismatch in converted bypass op")
s.Require().True(acc.IsWritable, "executor (transfer recipient) should be writable")
}
}
s.Require().True(found, "executor key must appear in the BypasserExecuteBatch remaining accounts")
}

// lamports returns the current lamport balance of the given account.
func (s *TestSuite) lamports(ctx context.Context, account solana.PublicKey) uint64 {
res, err := s.SolanaClient.GetBalance(ctx, account, rpc.CommitmentConfirmed)
s.Require().NoError(err)

return res.Value
}
13 changes: 13 additions & 0 deletions sdk/solana/chain_metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,19 @@ type AdditionalFieldsMetadata struct {
ProposerRoleAccessController solana.PublicKey `json:"proposerRoleAccessController" validate:"required"`
CancellerRoleAccessController solana.PublicKey `json:"cancellerRoleAccessController" validate:"required"`
BypasserRoleAccessController solana.PublicKey `json:"bypasserRoleAccessController" validate:"required"`
// ExecutePayer is the optional outer MCM execute fee payer (bypass only).
ExecutePayer *solana.PublicKey `json:"executePayer,omitempty"`
}

// WithExecutePayer returns a copy of f with ExecutePayer set to pk.
func (f AdditionalFieldsMetadata) WithExecutePayer(pk solana.PublicKey) AdditionalFieldsMetadata {
f.ExecutePayer = &pk
return f
}

// HasExecutePayer reports whether ExecutePayer is set to a non-zero public key.
func (f AdditionalFieldsMetadata) HasExecutePayer() bool {
return f.ExecutePayer != nil && !f.ExecutePayer.IsZero()
}

func (f AdditionalFieldsMetadata) Validate() error {
Expand Down
Loading
Loading