Durable Nonce Fan-out для SWQoS
Контекст и сценарий использования
При отправке Solana transactions продвижение slots, расположение leaders и перегрузка сетевых путей постоянно меняют то, какой маршрут быстрее всего достигает leader. Это не особенность какого-то одного RPC provider или sending service, а структурная характеристика модели исполнения Solana.
SWQoS endpoint от ERPC предоставляет путь отправки, который вводит transactions в priority lane, выделяемую leaders на основе Stake-weighted Quality of Service (SWQoS). Эта priority bandwidth примерно в 5 раз больше, чем у non-priority lane, и применяется еще до оценки priority fee.
Поэтому SWQoS endpoint - важная опция для отправки transactions, но в production один endpoint не всегда оказывается самым быстрым. Даже в пределах одного slot временные различия в path или перекос нагрузки могут привести к тому, что другой быстрый endpoint окажется впереди.
С учетом этого эффективным operational pattern может быть параллельная отправка одной и той же transaction по нескольким быстрым путям с принятием первого обработанного результата, а не зависимость от единственного маршрута.
Однако если отправить одну и ту же transaction на несколько endpoints, без дополнительного контроля нельзя гарантировать, что она исполнится только один раз. Fan-out без такого контроля может привести к нежелательному двойному исполнению или сломанной retry logic.
Для этого в Solana предусмотрен механизм Durable Nonce. Использование Durable Nonce позволяет отправлять одну и ту же подписанную transaction по нескольким маршрутам, при этом ограничивая on-chain execution одним исполнением.
На этой странице объясняется, как реализовать fan-out operations, которые сочетают SWQoS endpoint ERPC с другими быстрыми RPC endpoints, исходя из предположения, что отправка transaction выполняется с Durable Nonce.
Scope и prerequisites
Это руководство охватывает создание Durable Nonce account через web3.js и его использование для отправки transactions и fan-out operations.
Что нужно понимать заранее:
- Для Durable Nonce transactions используйте nonce value в качестве
recentBlockhashи ставьтеnonceAdvanceпервой instruction - После выполнения
nonceAdvancenonce может быть израсходован, даже если последующие instructions завершатся ошибкой. Повторно отправить тот же rawTx как есть уже нельзя. - Создание nonce account - это одноразовая настройка; обычно account затем переиспользуется
Шаг 1: Подготовьте nonce authority и connection
Nonce authority - это Keypair, который может продвигать nonce.
typescript
import {
Connection,
Keypair,
SystemProgram,
NONCE_ACCOUNT_LENGTH,
} from '@solana/web3.js'
const connection = new Connection(
'https://<primary-rpc-endpoint>',
'confirmed',
)
const nonceAuthority = Keypair.fromSecretKey(/* secret key */)import {
Connection,
Keypair,
SystemProgram,
NONCE_ACCOUNT_LENGTH,
} from '@solana/web3.js'
const connection = new Connection(
'https://<primary-rpc-endpoint>',
'confirmed',
)
const nonceAuthority = Keypair.fromSecretKey(/* secret key */)- Nonce authority может выполнять
nonceAdvance - Это может быть fee payer или отдельный keypair
Шаг 2: Сгенерируйте Keypair для nonce account
Nonce account - это System Account.
typescript
const nonceAccount = Keypair.generate()const nonceAccount = Keypair.generate()Этот Keypair используется для:
- хранения nonce value (замена
recentBlockhash) - подписи только в момент создания
- безопасного хранения после этого; в ежедневных отправках он уже не нужен
Шаг 3: Рассчитайте rent-exempt minimum
Nonce accounts должны быть rent-exempt. Не хардкодьте значения - получайте их из RPC.
typescript
const lamports =
await connection.getMinimumBalanceForRentExemption(
NONCE_ACCOUNT_LENGTH,
)const lamports =
await connection.getMinimumBalanceForRentExemption(
NONCE_ACCOUNT_LENGTH,
)Шаг 4: Создайте и инициализируйте nonce account
Создайте nonce account через createAccount + nonceInitialize в одной transaction.
typescript
import { Transaction } from '@solana/web3.js'
const tx = new Transaction()
tx.add(
SystemProgram.createAccount({
fromPubkey: nonceAuthority.publicKey,
newAccountPubkey: nonceAccount.publicKey,
lamports,
space: NONCE_ACCOUNT_LENGTH,
programId: SystemProgram.programId,
}),
SystemProgram.nonceInitialize({
noncePubkey: nonceAccount.publicKey,
authorizedPubkey: nonceAuthority.publicKey,
}),
)
// fee payer is the nonce authority
tx.feePayer = nonceAuthority.publicKey
// use a normal blockhash for initialization
const { blockhash, lastValidBlockHeight } =
await connection.getLatestBlockhash('confirmed')
tx.recentBlockhash = blockhash
// sign with both the new account and the authority
tx.sign(nonceAccount, nonceAuthority)
const signature = await connection.sendRawTransaction(tx.serialize())
await connection.confirmTransaction(
{ signature, blockhash, lastValidBlockHeight },
'confirmed',
)import { Transaction } from '@solana/web3.js'
const tx = new Transaction()
tx.add(
SystemProgram.createAccount({
fromPubkey: nonceAuthority.publicKey,
newAccountPubkey: nonceAccount.publicKey,
lamports,
space: NONCE_ACCOUNT_LENGTH,
programId: SystemProgram.programId,
}),
SystemProgram.nonceInitialize({
noncePubkey: nonceAccount.publicKey,
authorizedPubkey: nonceAuthority.publicKey,
}),
)
// fee payer is the nonce authority
tx.feePayer = nonceAuthority.publicKey
// use a normal blockhash for initialization
const { blockhash, lastValidBlockHeight } =
await connection.getLatestBlockhash('confirmed')
tx.recentBlockhash = blockhash
// sign with both the new account and the authority
tx.sign(nonceAccount, nonceAuthority)
const signature = await connection.sendRawTransaction(tx.serialize())
await connection.confirmTransaction(
{ signature, blockhash, lastValidBlockHeight },
'confirmed',
)Используйте этот
nonceAccount.publicKey для последующих отправок.Шаг 5: Получайте nonce перед каждой отправкой
Для каждой transaction получайте текущее nonce value.
typescript
import { NonceAccount } from '@solana/web3.js'
const { value, context } =
await connection.getAccountInfoAndContext(
nonceAccount.publicKey,
'confirmed',
)
if (!value) {
throw new Error('Nonce account not found')
}
const nonce = NonceAccount.fromAccountData(value.data).nonce
const minContextSlot = context.slotimport { NonceAccount } from '@solana/web3.js'
const { value, context } =
await connection.getAccountInfoAndContext(
nonceAccount.publicKey,
'confirmed',
)
if (!value) {
throw new Error('Nonce account not found')
}
const nonce = NonceAccount.fromAccountData(value.data).nonce
const minContextSlot = context.slot- Используйте
nonceкакrecentBlockhash context.slotиспользуется при confirmation
Шаг 6: Постройте transaction с Durable Nonce
Transactions с Durable Nonce должны удовлетворять следующим условиям:
recentBlockhash = nonce- Первая instruction - это
nonceAdvance
typescript
import {
TransactionMessage,
VersionedTransaction,
} from '@solana/web3.js'
const instructions = [
SystemProgram.nonceAdvance({
noncePubkey: nonceAccount.publicKey,
authorizedPubkey: nonceAuthority.publicKey,
}),
// add your real instructions after this
]
const message = new TransactionMessage({
payerKey: nonceAuthority.publicKey,
recentBlockhash: nonce,
instructions,
}).compileToV0Message()
const tx = new VersionedTransaction(message)
tx.sign([nonceAuthority /* + other signers */])
const rawTx = tx.serialize()import {
TransactionMessage,
VersionedTransaction,
} from '@solana/web3.js'
const instructions = [
SystemProgram.nonceAdvance({
noncePubkey: nonceAccount.publicKey,
authorizedPubkey: nonceAuthority.publicKey,
}),
// add your real instructions after this
]
const message = new TransactionMessage({
payerKey: nonceAuthority.publicKey,
recentBlockhash: nonce,
instructions,
}).compileToV0Message()
const tx = new VersionedTransaction(message)
tx.sign([nonceAuthority /* + other signers */])
const rawTx = tx.serialize()Примечания:
- Если вы используете instructions ComputeBudget, они должны идти после
nonceAdvance - Если
nonceAdvanceне стоит первым, transaction будет отклонена
Шаг 7: Выполните fan-out на несколько RPC
Отправьте один и тот же rawTx конкурентно.
typescript
const endpoints = [
'https://<erpc-swqos-fra>',
'https://<backup-rpc-1>',
'https://<backup-rpc-2>',
]
const results = await Promise.allSettled(
endpoints.map((url) =>
new Connection(url, 'confirmed').sendRawTransaction(rawTx, {
skipPreflight: true,
minContextSlot,
}),
),
)
const success = results.find(
(r): r is PromiseFulfilledResult<string> =>
r.status === 'fulfilled',
)
if (!success) {
throw new Error('All sends failed')
}
const signature = success.valueconst endpoints = [
'https://<erpc-swqos-fra>',
'https://<backup-rpc-1>',
'https://<backup-rpc-2>',
]
const results = await Promise.allSettled(
endpoints.map((url) =>
new Connection(url, 'confirmed').sendRawTransaction(rawTx, {
skipPreflight: true,
minContextSlot,
}),
),
)
const success = results.find(
(r): r is PromiseFulfilledResult<string> =>
r.status === 'fulfilled',
)
if (!success) {
throw new Error('All sends failed')
}
const signature = success.value- Signature будет одинаковой на всех endpoints
- Успешная отправка не означает confirmation
Шаг 8: Выполните confirmation с Durable Nonce
При использовании Durable Nonce включайте информацию о nonce в confirmation.
typescript
await connection.confirmTransaction(
{
signature,
nonceAccountPubkey: nonceAccount.publicKey,
nonceValue: nonce,
minContextSlot,
},
'confirmed',
)await connection.confirmTransaction(
{
signature,
nonceAccountPubkey: nonceAccount.publicKey,
nonceValue: nonce,
minContextSlot,
},
'confirmed',
)Если confirmation успешно:
- nonce продвигается
- Копии, отправленные в другие RPC, вернут
InvalidNonce
Следующие отправки
- После confirmation получайте свежий nonce
- Не переиспользуйте тот же rawTx или то же nonce value
- Для параллельных workflows используйте отдельные nonce accounts
Для следующей transaction получите обновленный nonce и снова соберите transaction по тем же шагам.