Act only on a fresh price
Pyth · Jupiter
Status: Tested locally in LiteSVM against Jupiter, Meteora and Pyth programs and accounts copied from mainnet; not yet run on devnet or mainnet.
Cost: Ballista's own work took 6,978 of the tested transaction's 80,062compute units; the protocols took the rest. Ballista charges no fee; see what it costs.
What it does
Reads a Pyth price during the transaction and runs a Jupiter swap only if the price passes your checks.
Inside a Solana program you would call Pyth's get_price_no_older_than. A transaction can't: it can read the price while it is being built, but it executes later, against whatever the price is then. In between, a publisher can stall and the market can move.
The template runs the swap only if all of these hold:
- the price account's verification level is
Full, which fixes where the other fields sit (see reading offsets); - the feed id is
feedId; - the exponent is
exponent; - the price was published at most
maximumAgeseconds ago; - the confidence interval is at most
maximumConfidence(a wide interval means the publishers disagree); - the price is between
floorPriceandceilingPrice, inclusive; - the route's
platformFeeBpsis at mostMAX_PLATFORM_FEE_BPS, a constant that is0(platformFeeWithinCap).
A feed id is the 32 bytes that name a Pyth feed, such as SOL/USD. Pyth's receiver program owns every feed's price account, so only the feed id says which feed an account holds.
Pyth publishes a price as price × 10^exponent. floorPrice, ceilingPrice and maximumConfidence are raw integers at exponent: at SOL/USD's exponent of −8, $100 is 10,000,000,000. If the feed's exponent changed, each would be off by a power of ten, so the exponent check fails the run instead.
It does not guard against:
- The swap itself. The route's token accounts arrive in the group, so nothing checks what the swap paid or which account it paid. Only Jupiter's
slippageBpsbounds the fill, against the route's own quote. Swap checked against an oracle checks both. - The run's builder. The feed, exponent, age, confidence and band are run inputs. The gate protects whoever builds the run from the price moving before the run lands, not the signer from that builder.
- The choice of price within
maximumAge. Whoever posts the price update can pick any Pyth price from that window. - Spending the actor's other token accounts. The actor signs
route, and Jupiter passes that authority to every step.
Template
import {
TOKEN_PROGRAM_ADDRESS_BYTES,
account,
compileTemplate,
data,
defineTemplate,
expression,
step,
} from '@jac0xb/ballista';
import { JUPITER_ROUTE, JUPITER_V6, PYTH, PYTH_RECEIVER, addressBytes } from './shared.js';
/** Valid only once the verification level has been pinned to `Full`; see the require below. */
const price = expression.accountData(account.fixed('priceUpdate'), PYTH.price, 'i64');
const confidence = expression.accountData(account.fixed('priceUpdate'), PYTH.confidence, 'u64');
const publishTime = expression.accountData(account.fixed('priceUpdate'), PYTH.publishTime, 'i64');
/** The route's platform fee account and rate are chosen by whoever builds the run: cap the rate. */
export const MAX_PLATFORM_FEE_BPS = 0n;
export const pythFreshPriceGate = defineTemplate({
inputs: {
/**
* The Pyth feed the price must come from, as its 32-byte id: SOL/USD's is
* `ef0d8b6fda2ceba41da15d4095d1da392a0d2f8ed0c6c7bc0f4cfac8c280b56d`.
*/
feedId: { type: 'pubkey' },
/**
* The feed's exponent, which the three bounds below are in units of: SOL/USD's is −8. The
* account holds it as an i32.
*/
exponent: { type: 'i64' },
/** How stale a price may be, in seconds. */
maximumAge: { type: 'i64' },
/** The widest confidence interval the caller will act on. */
maximumConfidence: { type: 'u64' },
floorPrice: { type: 'i64' },
ceilingPrice: { type: 'i64' },
/** `route_plan` as the Swap API encoded it: the bytes between the discriminator and `in_amount`. */
routePlan: { type: 'bytes', maxLength: 512 },
/** The route's `in_amount`. */
inAmount: { type: 'u64' },
/** The quote's `quoted_out_amount`. */
quotedOutAmount: { type: 'u64' },
/** The quote's `slippage_bps`. */
slippageBps: { type: 'u64' },
/** The quote's `platform_fee_bps`, at most `MAX_PLATFORM_FEE_BPS`. */
platformFeeBps: { type: 'u64' },
},
accounts: {
/**
* Pinning the owner is what makes the offsets meaningful: without it a caller could pass any
* account whose bytes happen to satisfy the comparisons. It does not say which feed the price
* belongs to; `priceIsTheExpectedFeed` does.
*/
priceUpdate: { owner: addressBytes(PYTH_RECEIVER), minDataLength: PYTH.length },
actionProgram: { executable: true, address: addressBytes(JUPITER_V6) },
tokenProgram: { executable: true, address: TOKEN_PROGRAM_ADDRESS_BYTES },
actor: { signer: true, writable: true },
},
accountGroups: ['actionAccounts'],
steps: [
// Fixes the layout. Without this the offsets below are a guess.
step.require(
expression.equal(
expression.accountData(account.fixed('priceUpdate'), PYTH.verificationLevel, 'u8'),
expression.u64(PYTH.verificationLevelFull),
),
'priceIsFullyVerified',
),
// Which feed the price belongs to. Its offset, like the rest, assumes the level just pinned.
step.require(
expression.equal(
expression.accountData(account.fixed('priceUpdate'), PYTH.feedId, 'pubkey'),
expression.input('feedId'),
),
'priceIsTheExpectedFeed',
),
// What the raw integers below mean. At another exponent each bound is off by a power of ten.
step.require(
expression.equal(
expression.accountData(account.fixed('priceUpdate'), PYTH.exponent, 'i32'),
expression.input('exponent'),
),
'priceExponentIsExpected',
),
step.require(
expression.lessThanOrEqual(
expression.subtract(expression.clockUnixTimestamp(), publishTime),
expression.input('maximumAge'),
),
'priceIsFresh',
),
// A wide confidence interval means the publishers disagree; treat it as no price at all.
step.require(
expression.lessThanOrEqual(confidence, expression.input('maximumConfidence')),
'publishersAgree',
),
step.require(expression.greaterThanOrEqual(price, expression.input('floorPrice')), 'priceAboveFloor'),
step.require(expression.lessThanOrEqual(price, expression.input('ceilingPrice')), 'priceBelowCeiling'),
// The fee account sits in the route's own accounts: any nonzero rate pays whoever chose it.
step.require(
expression.lessThanOrEqual(expression.input('platformFeeBps'), expression.u64(MAX_PLATFORM_FEE_BPS)),
'platformFeeWithinCap',
),
step.invoke({
program: account.fixed('actionProgram'),
accounts: [
{ account: account.fixed('tokenProgram'), signer: false, writable: false },
{ account: account.fixed('actor'), signer: true, writable: false },
],
accountGroup: 'actionAccounts',
data: [
data.literal(JUPITER_ROUTE),
data.encode('bytes', expression.input('routePlan')),
data.encode('u64', expression.input('inAmount')),
data.encode('u64', expression.input('quotedOutAmount')),
data.encode('u16', expression.input('slippageBps')),
data.encode('u8', expression.input('platformFeeBps')),
],
label: 'actOnTheOracle',
}),
],
});/// Act on a Jupiter route only while a fresh Pyth price sits inside a band.
pub fn pyth_fresh_price_gate() -> Template {
// Valid only once the verification level has been pinned to `Full`; see the first require.
let price = account_data("priceUpdate", PYTH_PRICE, ReadType::I64);
let confidence = account_data("priceUpdate", PYTH_CONFIDENCE, ReadType::U64);
let publish_time = account_data("priceUpdate", PYTH_PUBLISH_TIME, ReadType::I64);
Template::new()
// The feed the price must come from, as its 32-byte id.
.input("feedId", Type::Pubkey)
// The feed's exponent, which the bounds are in units of: SOL/USD's is −8.
.input("exponent", Type::I64)
// How stale a price may be, in seconds.
.input("maximumAge", Type::I64)
// The widest confidence interval the caller will act on.
.input("maximumConfidence", Type::U64)
.input("floorPrice", Type::I64)
.input("ceilingPrice", Type::I64)
.input("routePlan", Type::Bytes(512))
.input("inAmount", Type::U64)
.input("quotedOutAmount", Type::U64)
.input("slippageBps", Type::U64)
.input("platformFeeBps", Type::U64)
// Pinning the owner is what makes the offsets meaningful.
.account(
"priceUpdate",
account::readonly()
.owner(PYTH_RECEIVER)
.min_data_length(PYTH_LENGTH),
)
.account("actionProgram", account::program(JUPITER_V6))
.account("tokenProgram", account::program(TOKEN_PROGRAM_ID))
.account("actor", account::signer().writable())
.account_group("actionAccounts")
// Fixes the layout. Without this the offsets below are a guess.
.step(
step::require(
account_data("priceUpdate", PYTH_VERIFICATION_LEVEL, ReadType::U8)
.eq(u64(PYTH_VERIFICATION_LEVEL_FULL)),
)
.label("priceIsFullyVerified"),
)
// Which feed the price belongs to.
.step(
step::require(
account_data("priceUpdate", PYTH_FEED_ID, ReadType::Pubkey).eq(input("feedId")),
)
.label("priceIsTheExpectedFeed"),
)
// What the raw integers below mean.
.step(
step::require(
account_data("priceUpdate", PYTH_EXPONENT, ReadType::I32).eq(input("exponent")),
)
.label("priceExponentIsExpected"),
)
.step(
step::require((clock_unix_timestamp() - publish_time).lte(input("maximumAge")))
.label("priceIsFresh"),
)
// A wide confidence interval means the publishers disagree; treat it as no price at all.
.step(step::require(confidence.lte(input("maximumConfidence"))).label("publishersAgree"))
.step(step::require(price.clone().gte(input("floorPrice"))).label("priceAboveFloor"))
.step(step::require(price.lte(input("ceilingPrice"))).label("priceBelowCeiling"))
.step(platform_fee_within_cap())
.step(
step::invoke("actionProgram")
.readonly("tokenProgram")
.signer("actor")
.account_group("actionAccounts")
.data_parts(jupiter_route_data(
input("inAmount"),
input("quotedOutAmount"),
))
.label("actOnTheOracle"),
)
}import type { Address, Instruction } from '@solana/kit';
import { buildKitRunInstruction, type KitAccountBinding } from '@jac0xb/ballista/kit';
import { compiled } from '../pyth-fresh-price-gate.js';
import { JUPITER_V6, splitJupiterRoute } from '../shared.js';
import { TOKEN_PROGRAM, at, pinned } from './programs.js';
export function buildPythGateRun(input: {
templateAddress: Address;
/** The Pyth `PriceUpdateV2` account. */
priceUpdate: Address;
/**
* The feed the price must be, as 32 bytes: SOL/USD's is
* `ef0d8b6fda2ceba41da15d4095d1da392a0d2f8ed0c6c7bc0f4cfac8c280b56d`.
*/
feedId: Uint8Array;
/** The exponent the bounds below are in units of: SOL/USD's is -8. */
exponent: bigint;
actor: Address;
/** Seconds. */
maximumAge: bigint;
maximumConfidence: bigint;
floorPrice: bigint;
ceilingPrice: bigint;
/** The Swap API's `route` data. */
routeData: Uint8Array;
/** The route's account list from the third account on: the template passes the token program and the actor. */
actionAccounts: readonly KitAccountBinding[];
}): Instruction {
const route = splitJupiterRoute(input.routeData);
return buildKitRunInstruction({
compiled,
templateAddress: input.templateAddress,
inputs: {
feedId: input.feedId,
exponent: input.exponent,
maximumAge: input.maximumAge,
maximumConfidence: input.maximumConfidence,
floorPrice: input.floorPrice,
ceilingPrice: input.ceilingPrice,
routePlan: route.routePlan,
inAmount: route.inAmount,
quotedOutAmount: route.quotedOutAmount,
slippageBps: route.slippageBps,
platformFeeBps: route.platformFeeBps,
},
accounts: {
priceUpdate: at(input.priceUpdate),
actionProgram: pinned(JUPITER_V6),
tokenProgram: pinned(TOKEN_PROGRAM),
actor: at(input.actor),
},
accountGroups: { actionAccounts: input.actionAccounts },
});
}pub struct PriceGate {
/// The Pyth `PriceUpdateV2` account.
pub price_update: Pubkey,
/// The feed the price must be, as 32 bytes: SOL/USD's is
/// `ef0d8b6fda2ceba41da15d4095d1da392a0d2f8ed0c6c7bc0f4cfac8c280b56d`.
pub feed_id: [u8; 32],
/// The exponent the bounds are in units of: SOL/USD's is −8.
pub exponent: i32,
pub actor: Pubkey,
}
/// `route` is the Swap API's `route` data split by [`RouteQuote::split`], and `action_accounts`
/// its account list from the third account on: the template passes the token program and the
/// actor itself.
pub fn run_pyth_gate(
template: Pubkey,
gate: &PriceGate,
maximum_age: i64,
maximum_confidence: u64,
(floor_price, ceiling_price): (i64, i64),
route: &RouteQuote,
action_accounts: Vec<AccountMeta>,
) -> Result<Instruction, Box<dyn Error>> {
let instruction = templates::pyth_fresh_price_gate()
.compile()?
.run(template)
.input("feedId", gate.feed_id)
// Pyth stores the exponent as an i32; the template takes it as an i64.
.input("exponent", gate.exponent)
.input("maximumAge", maximum_age)
.input("maximumConfidence", maximum_confidence)
.input("floorPrice", floor_price)
.input("ceilingPrice", ceiling_price)
.input("routePlan", route.route_plan)
.input("inAmount", route.in_amount)
.input("quotedOutAmount", route.quoted_out_amount)
.input("slippageBps", route.slippage_bps)
.input("platformFeeBps", route.platform_fee_bps)
.account("priceUpdate", gate.price_update)
.account("actionProgram", JUPITER_V6)
.account("tokenProgram", TOKEN_PROGRAM_ID)
.account("actor", gate.actor)
.group("actionAccounts", action_accounts)
.instruction()?;
Ok(instruction)
}The Rust template takes its program addresses and jupiter_route_data() from the shared helpers.
Run it
The swap is Jupiter's route instruction, which starts its account list with the token program and the signer. The template passes those two itself; the rest of the route's accounts, including its token accounts, arrive as the actionAccounts account group.
The Run tabs pass the four declared accounts, priceUpdate, actionProgram, tokenProgram and actor, then the inputs feedId, exponent, maximumAge, maximumConfidence, floorPrice, ceilingPrice, routePlan, inAmount, quotedOutAmount, slippageBps and platformFeeBps, then the group. exponent is an i64 input, though Pyth stores it as an i32.
priceUpdate is a fully verified PriceUpdateV2 for the feed: the account Pyth's push oracle keeps updated for it, or an update you post with Pyth's receiver earlier in the transaction. A partially verified update fails the first check. The tests pass SOL/USD's push-oracle account, 7UVimffxr9ow1uXYxsr4LHAcV58mLzhmwaeKvJ1pjLiE, whose feed id is ef0d8b6fda2ceba41da15d4095d1da392a0d2f8ed0c6c7bc0f4cfac8c280b56d.
Getting a Jupiter route says how to request the route from Jupiter's Swap API and what to keep from its response.
What has been tested
- In LiteSVM.
tests/protocols/tests/pyth_fresh_price_gate.rssells1 SOLfor USDC through Jupiter and a Meteora pool, gated on Pyth's SOL/USD price, with the run in place ofroutein the transaction Jupiter's API built. With a fresh price in band, it fills exactly as that transaction does alone, for180more bytes. A price exactlymaximumAgeold passes, and so does a band of exactly the price. - Failures. A price one second past
maximumAgefails atpriceIsFresh, and one raw unit past either end of the band atpriceAboveFloororpriceBelowCeiling. An in-band price with another feed's id fails atpriceIsTheExpectedFeed, and the SOL/USD account with its exponent moved from −8to −7atpriceExponentIsExpected. No test fails the verification-level or confidence check. A route that charges a platform fee fails atplatformFeeWithinCap, before Jupiter is called. - An opt-in test checks the Pyth offsets against devnet accounts.