Skip to content

feat(sdk-coin-sol): rewrite fee payer for versioned custom transactions - #9849

Draft
ralph-bitgo[bot] wants to merge 2 commits into
CHALO-1652-fee-payer-rewrite-versioned-tx-pt1from
CHALO-1652-fee-payer-rewrite-versioned-tx-pt2
Draft

ralph-bitgo[bot] wants to merge 2 commits into
CHALO-1652-fee-payer-rewrite-versioned-tx-pt1from
CHALO-1652-fee-payer-rewrite-versioned-tx-pt2

Conversation

@ralph-bitgo

@ralph-bitgo ralph-bitgo Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

What

  • In CustomInstructionBuilder.fromVersionedTransactionData(), when feePayer() is set and differs from staticAccountKeys[0], the versioned message is rewritten so the fee payer becomes static account 0 (the fee payer of a versioned transaction is simply staticAccountKeys[0] as supplied, so .feePayer() previously had no effect):
    • fee payer not among the static keys → inserted at index 0 as a writable signer and numRequiredSignatures is incremented;
    • fee payer present elsewhere → moved to index 0: numRequiredSignatures is incremented if it was a non-signer, and the read-only count of the section it left is decremented (numReadonlySignedAccounts for a read-only signer, numReadonlyUnsignedAccounts for a read-only non-signer);
    • every static and lookup-table index is remapped with one full old → new map, so instructions keep targeting the same accounts;
    • a fee payer that is an address lookup table account is rejected (a table account can never sign).
  • The rewrite runs before injectNonceAdvanceInstruction(), so when the fee payer is also the nonce authority it is already a signer and carries a single signature, with AdvanceNonceAccount still instruction 0.
  • Adds EnterpriseFeePayerParams { feePayer: string } to src/lib/iface.ts as the named params type for sponsored builds.
  • Documents on fromVersionedTransactionData() that wallet-platform must NOT set a fee payer for a versioned transaction a third party has already signed (the rewrite invalidates existing signatures); wallet-platform builds those wallet-pays.

Why

  • Enterprises must fund SOL wallets one by one today just to pay network fees and rent (CHALO-983). The fee-payer design lets one enterprise fee address sponsor its wallets' transactions, but caller-built versioned transactions (customTx, WalletConnect) cannot be sponsored without this rewrite, because a versioned message's fee payer is whatever the caller put in staticAccountKeys[0]. The rewrite only happens when .feePayer() is called, so no bytes change for today's callers.
  • Verified compatible with CHALO-1651's open PR (fix(sdk-coin-sol): fix three bugs in injectNonceAdvanceInstruction #9846, the injectNonceAdvanceInstruction() fixes): merged its branch into this one on a throwaway branch and the full module suite passes (783 tests, including all fee-payer tests). The tests deliberately assert via indexOf-computed indexes instead of pinning nonce-injection header details, so fix(sdk-coin-sol): fix three bugs in injectNonceAdvanceInstruction #9846's fixes do not break them. The only merge conflict is textual in the test file (both PRs append a describe block at the same anchor); keeping both blocks resolves it.

Stack

This PR is part 2 of 2 in a stack. Review and merge in order:

  1. feat(sdk-coin-sol): add addFeePayerSignature() #9848 — addFeePayerSignature() (CHALO-996 dependency surface; base: master)
  2. feat(sdk-coin-sol): rewrite fee payer for versioned custom transactions #9849 — fee-payer rewrite for versioned custom transactions (base: CHALO-1652-fee-payer-rewrite-versioned-tx-pt1) ← you are here

Test plan

  • npx mocha test/unit/transactionBuilder/customInstructionBuilder.ts: fee payer inserted at account 0 with correct header and remapped indexes (message compiles and round-trips); a real Jupiter swap proves lookup-table instructions still target the same accounts after the +1 shift; non-signer / read-only non-signer / read-only signer moves with the matching header adjustments and no duplicate key
  • durable-nonce case: fee payer = nonce authority → one signature from it, AdvanceNonceAccount is instruction 0 with the authority at account 0
  • addFeePayerSignature() produces byte-identical output to the native sign flow (slot 0); non-account-0 keys are rejected
  • without feePayer() (and with feePayer() equal to the existing account 0) bytes are identical to a direct MessageV0 construction of the caller's data
  • guards: fee payer that is an address lookup table account and invalid fee payer addresses are rejected
  • npm run lint and tsc --build clean in modules/sdk-coin-sol
  • Full module unit suite green (773 passing)

Ticket: CHALO-1652

@linear-code

linear-code Bot commented Sep 29, 2026

Copy link
Copy Markdown
Contributor

CHALO-1652

@ralph-bitgo
ralph-bitgo Bot force-pushed the CHALO-1652-fee-payer-rewrite-versioned-tx-pt2 branch from 0f66fca to 4d5b460 Compare September 29, 2026 10:56
Add addFeePayerSignature(publicKey, signature) to Transaction and
TransactionBuilder, wrapping the existing addSignature() and
rejecting a key that is not account 0 of the message (the fee
payer). For versioned transactions account 0 is the first static
account key; for legacy transactions web3.js places the fee payer
first, so the signature lands in slot 0 on both.

Sponsored SOL transactions need the enterprise fee key's
signature in slot 0 (CHALO-983); SOL had no such method yet. This
is the CHALO-996 dependency surface of the fee-payer rewrite.

Ticket: CHALO-1652
Session-Id: c2138ca7-d8a2-46e6-8dd0-e00e22cc44c0
Task-Id: 40c147ec-2821-4dfc-a85f-85bc82e625fa
@ralph-bitgo
ralph-bitgo Bot force-pushed the CHALO-1652-fee-payer-rewrite-versioned-tx-pt1 branch from 29f424f to 71b0f65 Compare September 29, 2026 10:56
In fromVersionedTransactionData(), when feePayer() is set and
differs from staticAccountKeys[0], rewrite the message so the
fee payer becomes static account 0: insert it as a writable
signer, or move it there and adjust numRequiredSignatures plus
the header read-only counts of the section it left. Every static
and lookup-table index is remapped with one full old to new map,
so instructions keep targeting the same accounts, and a fee
payer that is an address lookup table account is rejected.

The rewrite runs before injectNonceAdvanceInstruction(), so a
fee payer that is also the nonce authority is already a signer
and ends up with a single signature. Without feePayer() the data
is used as supplied, so no bytes change for today's callers.

Today .feePayer() has no effect on versioned transactions, so
caller-built customTx / WalletConnect transactions cannot be
sponsored by the enterprise fee address (CHALO-983): the fee
payer of a versioned transaction is simply staticAccountKeys[0]
as the caller supplied it. Also add EnterpriseFeePayerParams to
iface.ts as the named params type for sponsored builds, and
document that wallet-platform must not rewrite a transaction a
third party has already signed (it would invalidate signatures);
those build wallet-pays.

Ticket: CHALO-1652
Session-Id: c2138ca7-d8a2-46e6-8dd0-e00e22cc44c0
Task-Id: 40c147ec-2821-4dfc-a85f-85bc82e625fa
@ralph-bitgo
ralph-bitgo Bot force-pushed the CHALO-1652-fee-payer-rewrite-versioned-tx-pt2 branch from 4d5b460 to d40972c Compare September 29, 2026 10:58
@MohammedRyaan786
MohammedRyaan786 force-pushed the CHALO-1652-fee-payer-rewrite-versioned-tx-pt1 branch from 71b0f65 to a0c5168 Compare October 7, 2026 06:17

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants