diff --git a/README.md b/README.md index cf9d97e..2bb0b27 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,41 @@ tx.setGasBudget(100_000_000); await wallet.signAndExecuteTransaction(tx); ``` +#### Supplying your own signed Lazer update + +By default the SDK fetches a signed Pyth Lazer payload from AlphaLend's proxy. If you have your own, +hand it over directly — every method that takes `priceUpdateCoinTypes` accepts `lazerUpdateBytes`, +and `updatePrices` / `updateAllPrices` / `updatePricesLazer` take it as a third argument: + +```typescript +await client.updatePrices(tx, coinTypes, myBytes); + +const tx = await client.borrow({ + /* ...normal params... */ + priceUpdateCoinTypes: coinTypes, + lazerUpdateBytes: myBytes, +}); +``` + +Pass the raw signed update as bytes — what sits behind the `hex` field of a Lazer response, and what +`pyth_lazer::parse_and_verify_le_ecdsa_update_v2` verifies on-chain. With bytes supplied, building a +transaction makes no request to AlphaLend's proxy, so a browser blocked by CORS or an outage on our +side doesn't stop you from transacting. + +Two things to get right. Lazer is a push stream: you hold one long-lived subscription over a fixed +set of numeric feed ids and a new signed payload arrives every second, each covering that whole set. + +- **Subscribe to at least the coins you transact on.** `oracle::ingest_lazer_update` skips absent + feeds rather than failing, so a coin your subscription omits keeps its previous price and shows up + later as a staleness abort that points nowhere near the real cause. Feed ids are numeric — read + the coin-type mapping on-chain with `oracle::get_lazer_feed_ids_for_coin` against + `ALPHAFI_ORACLE_OBJECT_ID`. +- **Pass the newest payload, not a held one.** Payloads carry a signed timestamp the on-chain + verifier bounds against the clock. + +On testnet and devnet the high-level methods skip Lazer entirely (the canonical testnet Lazer +package predates the v2 verifier), so `lazerUpdateBytes` only takes effect there via `updatePrices`. + ### Supply Collateral ```typescript diff --git a/__tests__/lazer.test.ts b/__tests__/lazer.test.ts index c9f87b8..e8e9dde 100644 --- a/__tests__/lazer.test.ts +++ b/__tests__/lazer.test.ts @@ -1,5 +1,7 @@ import { jest } from "@jest/globals"; import { Transaction } from "@mysten/sui/transactions"; +import { fromBase64 } from "@mysten/sui/utils"; +import { bcs } from "@mysten/sui/bcs"; import { appendLazerUpdate, fetchLazerUpdateBytes, @@ -36,7 +38,6 @@ const errStatus = (status = 503) => ({ function buildLazerClient(proxyUrl: string) { const client = new AlphalendClient("testnet", undefined, { coinMetadataMap: new Map(), - useLazer: true, }); client.constants.LAZER_PROXY_URL = proxyUrl; jest @@ -45,6 +46,28 @@ function buildLazerClient(proxyUrl: string) { return client; } +/** + * The signed payload the tx actually carries into parse_and_verify_le_ecdsa_update_v2. + * + * Resolved via the verify call's own argument rather than "the first Pure input" — real call sites + * (flashRepay) append pure inputs before the price update. + */ +function lazerPayloadOf(tx: Transaction): number[] { + const data = tx.getData(); + const verify = data.commands.find( + (command) => + command.MoveCall?.function === "parse_and_verify_le_ecdsa_update_v2", + ); + const arg = verify?.MoveCall?.arguments.at(-1); + if (arg?.$kind !== "Input") { + throw new Error("transaction carries no Lazer verify call"); + } + const input = data.inputs[arg.Input]; + if (input.$kind !== "Pure") + throw new Error("Lazer payload input is not pure"); + return [...bcs.vector(bcs.u8()).parse(fromBase64(input.Pure.bytes))]; +} + function expectCompleteLazerRefresh( tx: Transaction, constants: Constants, @@ -120,9 +143,9 @@ describe("fetchLazerUpdateBytes", () => { ok: true, json: async () => ({}), }); - await expect( - fetchLazerUpdateBytes("https://api.example"), - ).rejects.toThrow(/hex/i); + await expect(fetchLazerUpdateBytes("https://api.example")).rejects.toThrow( + /hex/i, + ); }); it("retries a transient failure, then succeeds", async () => { @@ -139,14 +162,18 @@ describe("fetchLazerUpdateBytes", () => { it("fails closed after exhausting retries (never falls back to Pyth)", async () => { const fetchMock = installFetch(); fetchMock.mockResolvedValue(errStatus(503)); - await expect(fetchLazerUpdateBytes("https://api.example")).rejects.toThrow(); + await expect( + fetchLazerUpdateBytes("https://api.example"), + ).rejects.toThrow(); expect(fetchMock).toHaveBeenCalledTimes(3); }); it("fails closed on a network-level error", async () => { const fetchMock = installFetch(); fetchMock.mockRejectedValue(new Error("ECONNREFUSED")); - await expect(fetchLazerUpdateBytes("https://api.example")).rejects.toThrow(); + await expect( + fetchLazerUpdateBytes("https://api.example"), + ).rejects.toThrow(); expect(fetchMock).toHaveBeenCalledTimes(3); }); }); @@ -187,29 +214,25 @@ describe("appendOracleToLendingBridge", () => { }); }); -describe("price-refresh routing (useLazer)", () => { - it("updatePrices routes to the Lazer path when useLazer is set", async () => { - const client = new AlphalendClient("mainnet", undefined, { - useLazer: true, - }); +describe("price-refresh routing", () => { + it("updatePrices routes to the Lazer path", async () => { + const client = new AlphalendClient("mainnet"); const spy = jest .spyOn(client, "updatePricesLazer") .mockImplementation(async () => {}); const tx = new Transaction(); await client.updatePrices(tx, ["0x2::sui::SUI"]); - expect(spy).toHaveBeenCalledWith(tx, ["0x2::sui::SUI"]); + expect(spy).toHaveBeenCalledWith(tx, ["0x2::sui::SUI"], undefined); }); - it("updateAllPrices routes to the Lazer path when useLazer is set", async () => { - const client = new AlphalendClient("mainnet", undefined, { - useLazer: true, - }); + it("updateAllPrices routes to the Lazer path", async () => { + const client = new AlphalendClient("mainnet"); const spy = jest .spyOn(client, "updatePricesLazer") .mockImplementation(async () => {}); const tx = new Transaction(); await client.updateAllPrices(tx, ["0x2::sui::SUI"]); - expect(spy).toHaveBeenCalledWith(tx, ["0x2::sui::SUI"]); + expect(spy).toHaveBeenCalledWith(tx, ["0x2::sui::SUI"], undefined); }); }); @@ -259,9 +282,7 @@ describe("Lazer price refresh", () => { } return okBody("0x010203"); }); - const client = new AlphalendClient("testnet", undefined, { - useLazer: true, - }); + const client = new AlphalendClient("testnet"); client.constants.LAZER_PROXY_URL = "https://api.example"; jest .spyOn(client.blockchain, "getInitialSharedVersion") @@ -282,6 +303,63 @@ describe("Lazer price refresh", () => { }); }); +describe("caller-supplied Lazer updates", () => { + it("per-call bytes are used as-is, with no provider and no proxy", async () => { + const fetchMock = installFetch(); + const client = buildLazerClient("https://api.example"); + const tx = new Transaction(); + + await client.updatePrices( + tx, + [client.constants.SUI_COIN_TYPE], + new Uint8Array([3, 1, 4]), + ); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(lazerPayloadOf(tx)).toEqual([3, 1, 4]); + expectCompleteLazerRefresh(tx, client.constants, 1); + }); + + it("per-call bytes take precedence over an installed provider", async () => { + installFetch(); + const provider = jest.fn(() => new Uint8Array([9, 9, 9])); + const client = buildLazerClient("https://api.example", provider); + const tx = new Transaction(); + + await client.updatePrices( + tx, + [client.constants.SUI_COIN_TYPE], + new Uint8Array([1, 2]), + ); + + expect(provider).not.toHaveBeenCalled(); + expect(lazerPayloadOf(tx)).toEqual([1, 2]); + }); + + it("per-call bytes reach a high-level method (withdraw)", async () => { + const fetchMock = installFetch(); + const client = new AlphalendClient("mainnet", undefined, { + coinMetadataMap: new Map(), + }); + jest + .spyOn(client.blockchain, "getInitialSharedVersion") + .mockResolvedValue("123"); + + const tx = await client.withdraw({ + marketId: "1", + amount: 1_000n, + coinType: client.constants.SUI_COIN_TYPE, + positionCapId: `0x${"1".repeat(64)}`, + address: `0x${"2".repeat(64)}`, + priceUpdateCoinTypes: [client.constants.SUI_COIN_TYPE], + lazerUpdateBytes: new Uint8Array([8, 8]), + }); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(lazerPayloadOf(tx)).toEqual([8, 8]); + }); +}); + describe("Pyth path intact (updatePriceTransaction)", () => { it("builds update_price_from_pyth + the lending bridge, with no Lazer calls", () => { const tx = new Transaction(); @@ -307,25 +385,10 @@ describe("Pyth path intact (updatePriceTransaction)", () => { }); }); -describe("default price source (no premature cutover)", () => { - it("a fresh client defaults to Pyth (useLazer=false) on every network", () => { - for (const network of ["mainnet", "testnet", "devnet"] as Network[]) { - expect(new AlphalendClient(network).useLazer).toBe(false); - } - }); - - it("lets apps opt into Lazer through client options", () => { - expect( - new AlphalendClient("mainnet", undefined, { useLazer: true }).useLazer, - ).toBe(true); - }); -}); - describe("Lazer fail-closed at the client boundary", () => { it("propagates a proxy failure and never falls back to the Pyth path", async () => { const client = new AlphalendClient("testnet", undefined, { coinMetadataMap: new Map(), - useLazer: true, }); installFetch().mockRejectedValue(new Error("ECONNREFUSED")); diff --git a/src/core/client.ts b/src/core/client.ts index 1545e8f..bb20cbb 100644 --- a/src/core/client.ts +++ b/src/core/client.ts @@ -16,8 +16,6 @@ import { } from "@mysten/sui/transactions"; import { appendOracleToLendingBridge, - getPriceInfoObjectIdsWithUpdate, - updatePriceTransaction, } from "../utils/oracle.js"; import { appendLazerUpdate, fetchLazerUpdateBytes } from "../utils/lazer.js"; import { @@ -87,7 +85,6 @@ export class AlphalendClient { blockchain: Blockchain; sevenKGateway: SevenKGateway; cetusSwap: CetusSwap; - useLazer: boolean; // Dynamic coin metadata properties private coinMetadataMap: Map = new Map(); @@ -148,7 +145,6 @@ export class AlphalendClient { this.blockchain = new Blockchain(network, graphqlUrl); this.sevenKGateway = new SevenKGateway(); this.cetusSwap = new CetusSwap("mainnet"); - this.useLazer = options?.useLazer ?? false; // If a coin metadata map is provided, use it and mark as initialized if (options?.coinMetadataMap) { @@ -168,10 +164,14 @@ export class AlphalendClient { return this.coinMetadataMap; } - async updatePricesLazer(tx: Transaction, coinTypes: string[]): Promise { + async updatePricesLazer( + tx: Transaction, + coinTypes: string[], + updateBytes?: Uint8Array, + ): Promise { if (coinTypes.length === 0) return; const [bytes, oracleInitialSharedVersion] = await Promise.all([ - fetchLazerUpdateBytes(this.constants.LAZER_PROXY_URL), + updateBytes ?? fetchLazerUpdateBytes(this.constants.LAZER_PROXY_URL), this.blockchain.getInitialSharedVersion( this.constants.ALPHAFI_ORACLE_OBJECT_ID, ), @@ -190,120 +190,20 @@ export class AlphalendClient { } } - /** - * Updates price information for assets from Pyth oracle - * - * This method: - * 1. Gathers price feed IDs for the specified coins - * 2. Fetches the latest price data from Pyth oracle - * 3. Adds price update instructions to the transaction - * 4. Updates the protocol with new price information - * - * @param tx - Transaction object to add price update calls to - * @param coinTypes - Array of fully qualified coin types (e.g., "0x2::sui::SUI") - * @returns Transaction object with price update calls - */ - async updatePrices(tx: Transaction, coinTypes: string[]) { - if (this.useLazer) { - await this.updatePricesLazer(tx, coinTypes); - return; - } - // Auto-initialize market data if needed - await this.ensureInitialized(); - - // De-duplicate: a coin appearing twice (e.g. supplied and borrowed) would - // otherwise add redundant Pyth verify + update_price moveCalls, bloating the - // transaction and gas with no benefit. Mirrors updateAllPrices(). - const uniqueCoinTypes = [...new Set(coinTypes)]; - - const updatePriceFeedIds: string[] = []; - for (const coinType of uniqueCoinTypes) { - if (!this.getPythSponsored(coinType)) { - updatePriceFeedIds.push(this.getPythPriceFeedId(coinType)); - } - } - - // The Pyth fetch (Hermes round-trip + price-feed moveCalls) and resolving - // the oracle's initial shared version are independent, so run them - // concurrently. getInitialSharedVersion only reads (it never mutates tx), - // so the Pyth update calls it appends still precede the update_price calls - // built in the loop below — transaction ordering is preserved. - const [, oracleInitialSharedVersion] = await Promise.all([ - updatePriceFeedIds.length > 0 - ? getPriceInfoObjectIdsWithUpdate( - tx, - updatePriceFeedIds, - this.pythClient, - this.pythConnection, - ) - : Promise.resolve(), - this.blockchain.getInitialSharedVersion( - this.constants.ALPHAFI_ORACLE_OBJECT_ID, - ), - ]); - - for (const coinType of uniqueCoinTypes) { - // Use dynamic data from GraphQL API - const priceInfoObjectId = this.getPythPriceInfoObjectId(coinType); - updatePriceTransaction( - tx, - { - priceInfoObject: priceInfoObjectId, - coinType: coinType, - }, - this.constants, - oracleInitialSharedVersion, - ); - } + async updatePrices( + tx: Transaction, + coinTypes: string[], + updateBytes?: Uint8Array, + ) { + await this.updatePricesLazer(tx, coinTypes, updateBytes); } - async updateAllPrices(tx: Transaction, coinTypes: string[]) { - if (this.useLazer) { - await this.updatePricesLazer(tx, coinTypes); - return; - } - // Auto-initialize market data if needed - await this.ensureInitialized(); - - // De-duplicate so a repeated coin doesn't add redundant update_price - // moveCalls in the loop below (the feed-id set was already de-duplicated). - const uniqueCoinTypes = [...new Set(coinTypes)]; - - // Use dynamic data with fallback to hardcoded - const updatePriceFeedIds: string[] = Array.from( - new Set( - uniqueCoinTypes.map((coinType) => this.getPythPriceFeedId(coinType)), - ), - ); - - // The Pyth fetch and resolving the oracle's initial shared version are - // independent; run them concurrently. getInitialSharedVersion only reads - // (never mutates tx), so the Pyth update calls still precede the - // update_price calls built in the loop below — ordering is preserved. - const [, oracleInitialSharedVersion] = await Promise.all([ - getPriceInfoObjectIdsWithUpdate( - tx, - updatePriceFeedIds, - this.pythClient, - this.pythConnection, - ), - this.blockchain.getInitialSharedVersion( - this.constants.ALPHAFI_ORACLE_OBJECT_ID, - ), - ]); - - for (const coinType of uniqueCoinTypes) { - const priceInfoObjectId = this.getPythPriceInfoObjectId(coinType); - updatePriceTransaction( - tx, - { - priceInfoObject: priceInfoObjectId, - coinType: coinType, - }, - this.constants, - oracleInitialSharedVersion, - ); - } + async updateAllPrices( + tx: Transaction, + coinTypes: string[], + updateBytes?: Uint8Array, + ) { + await this.updatePricesLazer(tx, coinTypes, updateBytes); } /** @@ -682,7 +582,11 @@ export class AlphalendClient { // First update prices to ensure latest oracle values if (this.network === "mainnet") { - await this.updatePrices(tx, params.priceUpdateCoinTypes); + await this.updatePrices( + tx, + params.priceUpdateCoinTypes, + params.lazerUpdateBytes, + ); } else { await setPrices(tx); } @@ -791,7 +695,11 @@ export class AlphalendClient { // First update prices to ensure latest oracle values if (this.network === "mainnet") { - await this.updatePrices(tx, params.priceUpdateCoinTypes); + await this.updatePrices( + tx, + params.priceUpdateCoinTypes, + params.lazerUpdateBytes, + ); } else { await setPrices(tx); } @@ -945,7 +853,11 @@ export class AlphalendClient { // First update prices to ensure latest oracle values if (this.network === "mainnet") { - await this.updatePrices(tx, params.priceUpdateCoinTypes); + await this.updatePrices( + tx, + params.priceUpdateCoinTypes, + params.lazerUpdateBytes, + ); } else { await setPrices(tx); } @@ -1268,7 +1180,11 @@ export class AlphalendClient { params.priceUpdateCoinTypes && params.priceUpdateCoinTypes.length > 0 ) { - await this.updatePrices(tx, params.priceUpdateCoinTypes); + await this.updatePrices( + tx, + params.priceUpdateCoinTypes, + params.lazerUpdateBytes, + ); } // Prefer the on-chain-derived claimable estimate over params.rewardAmounts: @@ -1526,7 +1442,11 @@ export class AlphalendClient { // reward-collection loop below, preserving transaction ordering. const [, { rewardInput, claimableAmounts }] = await Promise.all([ shouldUpdatePrices - ? this.updatePrices(tx, params.priceUpdateCoinTypes!) + ? this.updatePrices( + tx, + params.priceUpdateCoinTypes!, + params.lazerUpdateBytes, + ) : Promise.resolve(), getClaimRewardInput( this.blockchain, @@ -1681,9 +1601,17 @@ export class AlphalendClient { // First update prices to ensure latest oracle values if (this.network === "mainnet") { if (params.updateAllPrices) { - await this.updateAllPrices(tx, params.priceUpdateCoinTypes); + await this.updateAllPrices( + tx, + params.priceUpdateCoinTypes, + params.lazerUpdateBytes, + ); } else { - await this.updatePrices(tx, params.priceUpdateCoinTypes); + await this.updatePrices( + tx, + params.priceUpdateCoinTypes, + params.lazerUpdateBytes, + ); } } else { await setPrices(tx); diff --git a/src/core/flashRepay.ts b/src/core/flashRepay.ts index 1e4f06d..036b35d 100644 --- a/src/core/flashRepay.ts +++ b/src/core/flashRepay.ts @@ -255,7 +255,11 @@ export async function buildFlashRepayTransaction( // Step 4: Update oracle prices — refresh on-chain oracle prices for all coins in the position so withdraw/collateral math is correct (mainnet only). if (client.network === "mainnet") { - await client.updatePrices(tx, priceUpdateCoinTypes); + await client.updatePrices( + tx, + priceUpdateCoinTypes, + params.lazerUpdateBytes, + ); } // Step 5: Withdraw collateral — remove collateral in withdraw-coin (amount = flash loan value + slippage buffer so enough after swap to repay Navi). Uses promise so the withdrawn coin can be used in the next step. diff --git a/src/core/types.ts b/src/core/types.ts index 7713aab..379cf9a 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -20,7 +20,6 @@ import { Decimal } from "decimal.js"; */ export interface AlphalendClientOptions { coinMetadataMap?: Map; - useLazer?: boolean; } /** @@ -111,6 +110,8 @@ export interface WithdrawParams { /** Coin types of the coins whose price needs to be updated * (Will have to pass all market coin types that user has supplied or borrowed in and current market coin type in which user is withdrawing) */ priceUpdateCoinTypes: string[]; + /** Your own signed Lazer update for this transaction; skips the fetch from AlphaLend's proxy. */ + lazerUpdateBytes?: Uint8Array; } /** @@ -133,6 +134,8 @@ export interface ZapOutWithdrawParams { /** Coin types of the coins whose price needs to be updated * (Will have to pass all market coin types that user has supplied or borrowed in and current market coin type in which user is withdrawing) */ priceUpdateCoinTypes: string[]; + /** Your own signed Lazer update for this transaction; skips the fetch from AlphaLend's proxy. */ + lazerUpdateBytes?: Uint8Array; /** Slippage for the swap (e.g., 0.01 for 1%) */ slippage: number; /** Withdraw coin type (e.g., "0x2::sui::SUI") */ @@ -182,6 +185,8 @@ export interface BorrowParams { /** Coin types of the coins whose price needs to be updated * (Will have to pass all market coin types that user has supplied or borrowed in and current market coin type in which user is borrowing) */ priceUpdateCoinTypes: string[]; + /** Your own signed Lazer update for this transaction; skips the fetch from AlphaLend's proxy. */ + lazerUpdateBytes?: Uint8Array; } /** @@ -256,6 +261,8 @@ export interface ClaimSwapAndSupplyOrRepayOrTransferParams { slippage: number; /** Coin types of user's supplied assets (for price updates) */ priceUpdateCoinTypes: string[]; + /** Your own signed Lazer update for this transaction; skips the fetch from AlphaLend's proxy. */ + lazerUpdateBytes?: Uint8Array; /** Optional map of reward coin types to their amounts in base units; used as fallback when the on-chain claimable estimate is unavailable */ rewardAmounts?: Map; /** If true, claims rewards < $0.01 directly to wallet (only swap checkbox case) */ @@ -287,6 +294,8 @@ export interface ClaimAndSupplyOrRepayParams { supplyMarkets?: Map; /** Coin types for price updates */ priceUpdateCoinTypes: string[]; + /** Your own signed Lazer update for this transaction; skips the fetch from AlphaLend's proxy. */ + lazerUpdateBytes?: Uint8Array; } /** @@ -311,6 +320,8 @@ export interface LiquidateParams { /** Coin types of the coins whose price needs to be updated * (Will have to pass all market coin types that user has supplied or borrowed in and current market coin type in which is being liquidated) */ priceUpdateCoinTypes: string[]; + /** Your own signed Lazer update for this transaction; skips the fetch from AlphaLend's proxy. */ + lazerUpdateBytes?: Uint8Array; /** Whether to update all prices */ updateAllPrices?: boolean; } @@ -459,4 +470,6 @@ export interface FlashRepayParams { address: string; slippage: number; repayAmountBaseUnits?: string; + /** Your own signed Lazer update for this transaction; skips the fetch from AlphaLend's proxy. */ + lazerUpdateBytes?: Uint8Array; }