Skip to content
Merged
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
7 changes: 7 additions & 0 deletions .changeset/solana-sponsored-send-supported-rpc.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@openfort/react": patch
---

Fixed Solana sponsored sends, which failed with `Unknown method: transferTransaction` before broadcast. The paymaster method the send flow relied on is no longer routed by the Openfort Solana RPC endpoint, so `walletConfig.solana.sponsorFees` could not complete a transfer. The transfer instructions are now built client-side and submitted through `signAndSendTransaction`, which is the supported path.

Added `{ feeToken }` as a value for `walletConfig.solana.sponsorFees`, for projects whose gas sponsorship charges the end user in an SPL token instead of paying the fee for them. The fee payment instruction is quoted from the paymaster and appended to the transfer, and is rejected if it names an account outside that fee transfer.
4 changes: 4 additions & 0 deletions .gitleaks.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,8 @@ regexes = [
# anvil's first prefunded development account. The key is printed by anvil on
# every start and controls funds only inside a local fork.
'''0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80''',
# USDC's mainnet SPL mint. A mint address is a public on-chain identifier, but
# it is high-entropy base58 next to the word "token", which is what the
# generic-api-key rule looks for.
'''EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v''',
]
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,10 @@ export const SolanaSendConfirmation = () => {
const decimals = asset.type === 'spl' ? asset.metadata.decimals : SOL_DECIMALS

// Fees are sponsored from config (the SVM counterpart of ethereumFeeSponsorshipId).
const isSponsored = Boolean(walletConfig?.solana?.sponsorFees)
// An object form also names the SPL mint the user pays the fee in.
const sponsorFees = walletConfig?.solana?.sponsorFees
const isSponsored = Boolean(sponsorFees)
const feeToken = typeof sponsorFees === 'object' ? sponsorFees.feeToken : undefined

// Real network fee from the RPC (getFeeForMessage). Null while loading or on failure.
const feeQuery = useQuery({
Expand Down Expand Up @@ -118,9 +121,11 @@ export const SolanaSendConfirmation = () => {
to: recipient,
mint: asset.address,
amount: baseUnits,
decimals,
provider,
cluster,
publishableKey,
feeToken,
rpcUrl: rpcUrl ?? undefined,
commitment,
})
Expand All @@ -143,6 +148,7 @@ export const SolanaSendConfirmation = () => {
provider,
cluster,
publishableKey,
feeToken,
rpcUrl: rpcUrl ?? undefined,
commitment,
})
Expand Down
1 change: 1 addition & 0 deletions packages/openfort-react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ export type {
ConnectedEmbeddedSolanaWallet,
SetActiveSolanaWalletOptions,
SolanaConfig,
SolanaFeeSponsorship,
SolanaWalletState,
UseEmbeddedSolanaWalletOptions,
} from './solana/types.js'
Expand Down
1 change: 1 addition & 0 deletions packages/openfort-react/src/solana/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export type {
SolanaCluster,
SolanaCommitment,
SolanaConfig,
SolanaFeeSponsorship,
SolanaSignAllTransactionsRequest,
SolanaSignMessageRequest,
SolanaSignTransactionRequest,
Expand Down
207 changes: 180 additions & 27 deletions packages/openfort-react/src/solana/transfer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { ValidationError } from '../errors/validation.js'
import { WalletError } from '../errors/wallet.js'
import { getOpenfortBackendUrl } from '../openfort/core/client.js'
import {
assertKoraInstructionsAreExpected,
assertKoraPaymentIsExpected,
assertTransferableRecipient,
koraRpcUrl,
resolveTokenProgram,
Expand Down Expand Up @@ -131,6 +131,7 @@ describe('sendSplTokenGasless', () => {
to: 'token-account',
mint: 'mint-address',
amount: 1_000n,
decimals: 6,
provider: {} as never,
cluster: 'devnet',
publishableKey: 'pk_test',
Expand All @@ -144,50 +145,202 @@ describe('sendSplTokenGasless', () => {
})
})

describe('assertKoraInstructionsAreExpected', () => {
describe('assertKoraPaymentIsExpected', () => {
const FEE_TOKEN = 'fee-token-mint'
const SOURCE_ATA = 'user-fee-token-account'
const DESTINATION_ATA = 'kora-fee-token-account'
const FROM = 'sender-wallet'
const RECIPIENT = 'recipient-wallet'
const RECIPIENT_ATA = 'recipient-associated-token-account'
const EXPECTED = [FROM, SOURCE_ATA, DESTINATION_ATA, FEE_TOKEN, 'token-program']
const ix = (...addresses: string[]) => ({ accounts: addresses.map((address) => ({ address })) })
const payment = (overrides: Record<string, unknown> = {}) => ({
payment_token: FEE_TOKEN,
payment_amount: 1_500,
payment_instruction: ix(SOURCE_ATA, DESTINATION_ATA, FROM),
...overrides,
})

/**
* The common sponsored SPL send: the recipient already holds the token, so
* Kora returns a lone `transferChecked` naming the destination *token
* account* and never the wallet.
*/
it('accepts a transfer that names the destination token account', () => {
it('accepts a payment that only touches the fee transfer', () => {
expect(() =>
assertKoraInstructionsAreExpected([ix('source-ata', 'mint', RECIPIENT_ATA, FROM)], {
from: FROM,
acceptableDestinations: [RECIPIENT, RECIPIENT_ATA],
})
assertKoraPaymentIsExpected(payment(), { feeToken: FEE_TOKEN, expectedAccounts: EXPECTED })
).not.toThrow()
})

it('accepts a transfer that names the recipient wallet, as a first send does', () => {
it('accepts a zero fee, which is what free paymaster pricing quotes', () => {
expect(() =>
assertKoraInstructionsAreExpected([ix(FROM, RECIPIENT)], {
from: FROM,
acceptableDestinations: [RECIPIENT],
})
assertKoraPaymentIsExpected(payment({ payment_amount: 0 }), { feeToken: FEE_TOKEN, expectedAccounts: EXPECTED })
).not.toThrow()
})

it('rejects a transaction that redirects to somewhere never requested', () => {
it('rejects a payment quoted in a different token', () => {
expect(() =>
assertKoraPaymentIsExpected(payment({ payment_token: 'other-mint' }), {
feeToken: FEE_TOKEN,
expectedAccounts: EXPECTED,
})
).toThrow(WalletError)
})

it.each([
undefined,
-1,
1.5,
Number.NaN,
Number.MAX_SAFE_INTEGER + 2,
])('rejects the unusable fee amount %s', (amount) => {
expect(() =>
assertKoraInstructionsAreExpected([ix('source-ata', 'mint', 'attacker-ata', FROM)], {
from: FROM,
acceptableDestinations: [RECIPIENT, RECIPIENT_ATA],
assertKoraPaymentIsExpected(payment({ payment_amount: amount }), {
feeToken: FEE_TOKEN,
expectedAccounts: EXPECTED,
})
).toThrow(WalletError)
})

it('rejects a transaction that debits a different sender', () => {
it('rejects a payment that debits an account outside the fee transfer', () => {
expect(() =>
assertKoraInstructionsAreExpected([ix('other-wallet', RECIPIENT_ATA)], {
from: FROM,
acceptableDestinations: [RECIPIENT, RECIPIENT_ATA],
assertKoraPaymentIsExpected(payment({ payment_instruction: ix(SOURCE_ATA, 'attacker-ata', FROM) }), {
feeToken: FEE_TOKEN,
expectedAccounts: EXPECTED,
})
).toThrow(WalletError)
})
})

describe('sendSolGasless', () => {
const FROM = 'sender-wallet'
const RECIPIENT = 'recipient-wallet'
const KORA_SIGNER = 'kora-fee-payer'
const FEE_TOKEN = 'fee-token-mint'
const SPL_TOKEN_PROGRAM = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'

/**
* Enough of `@solana/kit` to build and encode a message. The fakes carry the
* instruction list through unchanged so a test can assert on what was built.
*/
const mockKit = () => ({
createSolanaRpc: () => ({
getAccountInfo: () => ({ send: async () => ({ value: { owner: '11111111111111111111111111111111' } }) }),
}),
address: (value: string) => value,
lamports: (value: bigint) => value,
createNoopSigner: (address: string) => ({ address }),
pipe: (value: unknown, ...fns: ((input: unknown) => unknown)[]) => fns.reduce((acc, fn) => fn(acc), value),
createTransactionMessage: () => ({ instructions: [] as unknown[] }),
setTransactionMessageFeePayerSigner: (feePayer: unknown, tx: object) => ({ ...tx, feePayer }),
setTransactionMessageLifetimeUsingBlockhash: (lifetime: unknown, tx: object) => ({ ...tx, lifetime }),
appendTransactionMessageInstructions: (instructions: unknown[], tx: { instructions: unknown[] }) => ({
...tx,
instructions: [...tx.instructions, ...instructions],
}),
appendTransactionMessageInstruction: (instruction: unknown, tx: { instructions: unknown[] }) => ({
...tx,
instructions: [...tx.instructions, instruction],
}),
partiallySignTransactionMessageWithSigners: async (tx: object) => ({
...tx,
messageBytes: new Uint8Array([1]),
signatures: {},
}),
getBase64EncodedWireTransaction: () => 'base64-wire',
getBase58Encoder: () => ({ encode: () => new Uint8Array(64) }),
getBase58Decoder: () => ({ decode: () => 'decoded-signature' }),
})

const setup = (koraOverrides: Record<string, unknown> = {}) => {
const kora = {
getPayerSigner: vi.fn(async () => ({ signer_address: KORA_SIGNER })),
getBlockhash: vi.fn(async () => ({ blockhash: 'blockhash' })),
signAndSendTransaction: vi.fn(async () => ({ signature: 'tx-signature' })),
transferTransaction: vi.fn(),
...koraOverrides,
}
vi.doMock('@solana/kit', () => mockKit())
vi.doMock('@solana/kora', () => ({
KoraClient: function KoraClient() {
return kora
},
}))
vi.doMock('@solana-program/system', () => ({
getTransferSolInstruction: vi.fn((input: unknown) => ({ kind: 'transferSol', input })),
}))
vi.doMock('@solana-program/token', () => ({
TOKEN_PROGRAM_ADDRESS: SPL_TOKEN_PROGRAM,
findAssociatedTokenPda: async ({ owner }: { owner: string }) => [`${owner}-ata`],
}))
return kora
}

const unmock = () => {
for (const id of ['@solana/kit', '@solana/kora', '@solana-program/system', '@solana-program/token']) {
vi.doUnmock(id)
}
vi.resetModules()
}

const send = async (overrides: Record<string, unknown> = {}) => {
const { sendSolGasless: send } = await import('./transfer.js')
return send({
from: FROM,
to: RECIPIENT,
amountSol: 1,
provider: { signTransaction: async () => ({ signature: 'user-signature' }) } as never,
cluster: 'devnet',
publishableKey: 'pk_test',
...overrides,
} as never)
}

/**
* The hosted Openfort endpoint does not route `transferTransaction`, so a
* sponsored send that reaches for it fails before broadcast.
*/
it('builds the transfer locally instead of asking the paymaster for one', async () => {
const kora = setup()
await expect(send()).resolves.toBe('tx-signature')

expect(kora.transferTransaction).not.toHaveBeenCalled()
expect(kora.getPayerSigner).toHaveBeenCalledTimes(1)
expect(kora.signAndSendTransaction).toHaveBeenCalledWith(
expect.objectContaining({ transaction: 'base64-wire', signer_key: KORA_SIGNER })
)
unmock()
})

it('appends the paymaster fee payment when the user pays in a token', async () => {
const getPaymentInstruction = vi.fn(async () => ({
payment_token: FEE_TOKEN,
payment_amount: 2_500,
payment_address: KORA_SIGNER,
payment_instruction: { accounts: [{ address: `${FROM}-ata` }, { address: `${KORA_SIGNER}-ata` }] },
}))
const kora = setup({ getPaymentInstruction })

await expect(send({ feeToken: FEE_TOKEN })).resolves.toBe('tx-signature')

expect(getPaymentInstruction).toHaveBeenCalledWith(
expect.objectContaining({ fee_token: FEE_TOKEN, source_wallet: FROM, signer_key: KORA_SIGNER })
)
expect(kora.signAndSendTransaction).toHaveBeenCalledTimes(1)
unmock()
})

it('does not pay a fee the paymaster routed to somebody else', async () => {
setup({
getPaymentInstruction: async () => ({
payment_token: FEE_TOKEN,
payment_amount: 2_500,
payment_address: KORA_SIGNER,
payment_instruction: { accounts: [{ address: 'attacker-ata' }] },
}),
})

await expect(send({ feeToken: FEE_TOKEN })).rejects.toThrow('fee payment for a different account')
unmock()
})

it('reports an @solana/kora too old to build a fee payment', async () => {
setup({ getPaymentInstruction: undefined })

await expect(send({ feeToken: FEE_TOKEN })).rejects.toThrow('getPaymentInstruction')
unmock()
})
})
Loading
Loading