Skip to content
Merged
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
133 changes: 98 additions & 35 deletions __tests__/lazer.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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);
});
});
Expand Down Expand Up @@ -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);
});
});

Expand Down Expand Up @@ -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")
Expand All @@ -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();
Expand All @@ -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"));

Expand Down
Loading
Loading