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
16 changes: 16 additions & 0 deletions .github/workflows/ci-lazer-stellar.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,19 @@ jobs:
run: cargo clippy --release --target wasm32v1-none --locked -- --deny warnings
- name: Build wasm
run: cargo build --release --target wasm32v1-none --locked

lazer-stellar-client:
name: Lazer Stellar Demo Client
runs-on: ubuntu-22.04
defaults:
run:
working-directory: lazer/stellar/client
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
# Static checks only: the demo itself needs a Lazer token, a funded Stellar
# account and a live network, so it cannot run in CI.
- run: npm test
75 changes: 70 additions & 5 deletions lazer/stellar/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,19 @@ integration on Stellar:
This is an example, not a production library — it tracks exactly one feed and keeps only the most
recent price. The main implementation lives in [`src/lib.rs`](./src/lib.rs).

The [`client/`](./client) directory holds a standalone Node client that drives the whole flow in one
command: it fetches a freshly signed update from Pyth Lazer, submits it to a deployed instance of
this contract, and reads the stored price back.

## Prerequisites

- A Rust toolchain (minimum **1.84**) with the `wasm32v1-none` target:
```bash
rustup target add wasm32v1-none
```
- The [Stellar CLI](https://developers.stellar.org/docs/tools/developer-tools/cli/install-cli).
- Node.js 20 or newer, to run the demo client.
- A Pyth Lazer access token, exported as `PYTH_API_KEY`.

## Build

Expand All @@ -48,7 +54,7 @@ mainnet id as `--lazer` if you deploy there instead:
Configure a funded testnet identity once:

```bash
stellar keys generate deployer --network testnet
stellar keys generate deployer --network testnet --fund
```

Build and deploy the example, passing the constructor args (verifier address, feed id, freshness
Expand All @@ -65,22 +71,81 @@ stellar contract deploy \
--freshness_threshold_us 60000000
```

Then submit a signed Lazer update and read it back:
The command prints the deployed contract id (`C...`). Export it:

```bash
export EXAMPLE_CONTRACT_ADDRESS=<deployed contract id>
```

## Run the demo client

The client fetches a signed update, calls `update_price`, and reads `get_price` back — one command,
no monorepo checkout required:

```bash
cd client
npm install
export PYTH_API_KEY=<your Pyth Lazer access token>
npm run demo -- --network testnet --contract-id "$EXAMPLE_CONTRACT_ADDRESS"
```

With no `--secret` and no `STELLAR_WALLET_SECRET`, the client generates a throwaway keypair and
friendbot-funds it on testnet. On mainnet a funded account is required:

```bash
export STELLAR_WALLET_SECRET=<S... secret key of a funded mainnet account>
npm run demo -- --network mainnet --contract-id "$EXAMPLE_CONTRACT_ADDRESS"
```

Run `npm run demo -- --help` for the full option list (`--feed-id`, `--channel`,
`--lazer-endpoint`, `--secret`).

See [`client/README.md`](./client/README.md) for the sample output and the failure modes.

## Invoking the contract directly

The client is the supported way to produce a payload, because the update has to be signed by Lazer
and fresh. If you want to drive the contract from the Stellar CLI instead, take the `Update hex`
line the client prints and pass it straight through — but note the payload goes stale within the
contract's `freshness_threshold_us`, so fetch and submit in the same breath:

```bash
UPDATE_HEX=$(curl -sS -X POST https://pyth-lazer-0.dourolabs.app/v1/latest_price \
-H "Authorization: Bearer $PYTH_API_KEY" \
-H "Content-Type: application/json" \
-d '{"priceFeedIds":[1],"properties":["price","exponent","feedUpdateTimestamp"],"formats":["leEcdsa"],"jsonBinaryEncoding":"hex","channel":"fixed_rate@200ms"}' \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["leEcdsa"]["data"])')

stellar contract invoke \
--id <EXAMPLE_CONTRACT_ADDRESS> \
--id "$EXAMPLE_CONTRACT_ADDRESS" \
--source deployer \
--network testnet \
-- update_price --payload <HEX_ENCODED_UPDATE>
-- update_price --payload "$UPDATE_HEX"

stellar contract invoke \
--id <EXAMPLE_CONTRACT_ADDRESS> \
--id "$EXAMPLE_CONTRACT_ADDRESS" \
--source deployer \
--network testnet \
-- get_price
```

`properties` must list `price`, `exponent` **and** `feedUpdateTimestamp`: `update_price` reads all
three, and a property that is not requested decodes to `None` on-chain, failing the call with
`PriceMissing` / `ExponentMissing` / `TimestampMissing`. `formats` must be `leEcdsa`, which is the
signature format the Soroban verifier accepts.

## Failure modes worth knowing

`update_price` is deliberately strict. The two you will hit while experimenting:

- **`PriceStale` (error #1)** — the update's feed timestamp lags ledger time by more than
`freshness_threshold_us`. Fetch a new update.
- **`PriceOutdated` (error #9)** — the stored price is at least as new as the update you submitted.
Updates are strictly monotonic, so **replaying a cached payload always fails**. Re-running the
demo works because it fetches a fresh update every run.

The client maps these codes to a plain-English explanation before dumping the raw diagnostics.

## Additional Resources

- The Pyth Lazer consumer guide on [docs.pyth.network/lazer](https://docs.pyth.network/lazer).
Expand Down
1 change: 1 addition & 0 deletions lazer/stellar/client/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
node_modules/
1 change: 1 addition & 0 deletions lazer/stellar/client/.prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
node_modules
1 change: 1 addition & 0 deletions lazer/stellar/client/.prettierrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
92 changes: 92 additions & 0 deletions lazer/stellar/client/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# Pyth Lazer Stellar demo client

A standalone Node client for the [example consumer contract](../README.md). One command runs the
whole consumer story:

1. Fetch a freshly signed Pyth Lazer price update over REST, carrying the three properties the
contract needs (`price`, `exponent`, `feedUpdateTimestamp`).
2. Print the update hex and the off-chain price, for comparison.
3. Submit it to the deployed example consumer's `update_price`.
4. Read `get_price` back and print the stored price in human units.

It depends only on `@stellar/stellar-sdk` and talks to Lazer with plain `fetch`, so it runs outside
the Pyth monorepos.

## Usage

```bash
npm install
export PYTH_API_KEY=<your Pyth Lazer access token>
npm run demo -- --network testnet --contract-id <EXAMPLE_CONTRACT_ADDRESS>
```

Deploy the example consumer first — see the [parent README](../README.md).

| Option | Default | Notes |
| ------------------ | ---------------------------------------------------- | --------------------------------------------------- |
| `--contract-id` | _required_ | Deployed example consumer contract id. |
| `--network` | `testnet` | `testnet` or `mainnet`. |
| `--secret` | `$STELLAR_WALLET_SECRET` | Stellar secret key. Required on mainnet. |
| `--feed-id` | `1` (BTC/USD) | Must match the feed the contract was deployed with. |
| `--channel` | `fixed_rate@200ms` | Lazer channel. |
| `--lazer-endpoint` | `https://pyth-lazer-0.dourolabs.app/v1/latest_price` | Lazer `latest_price` REST endpoint. |

On testnet, an account that is missing or unfunded is topped up from friendbot automatically — with
no secret at all the client generates a throwaway keypair. Mainnet has no friendbot, so
`STELLAR_WALLET_SECRET` (or `--secret`) must hold a funded account. The client prints the signing
account's public key and XLM balance before it spends anything.

On mainnet the client bids an inclusion fee of `1000000` stroops (0.1 XLM). Mainnet ledgers run
close to full and a base-fee bid gets evicted, so the transaction would expire without ever reaching
a ledger.

## Sample output

```
=== Pyth Lazer Stellar demo (testnet) ===
Example consumer: CCEZSNTTVRIYXYHBKD2OMBRQI3RP6ZNXFWQ5ZG5ZDTQ7JZ4DTZ6FR72Q
Explorer: https://stellar.expert/explorer/testnet/contract/CCEZSNTTVRIYXYHBKD2OMBRQI3RP6ZNXFWQ5ZG5ZDTQ7JZ4DTZ6FR72Q

=== Signing account (from --secret / $STELLAR_WALLET_SECRET) ===
Public key: GBTKQ2WYNYE5TJW3N5LH7PGGERX2EIBDIIN7X6LFHMVQQZCZT36KA3PC
XLM balance: 9999.9995630

=== Fetching signed Lazer update (feed 1, fixed_rate@200ms) ===
Update size: 112 bytes
Update hex: e4bd474d77cb5c62...c04301cd995b0600
Off-chain price: 75549.21621105 (raw 7554921621105, exponent -8)
Feed timestamp: 1789565987800000 us (2026-09-16T13:39:47.800Z)

=== Submitting update_price to the example consumer ===
Transaction hash: 10c8e82aed30bfb2f3de6848f8729c4288a2c0e791fc280299700070682ffcb9
Explorer: https://stellar.expert/explorer/testnet/tx/10c8e82aed30bfb2f3de6848f8729c4288a2c0e791fc280299700070682ffcb9
Status: SUCCESS (ledger 4708481)

=== Reading get_price back from the contract ===
Stored price: 75549.21621105
Raw price: 7554921621105 (exponent -8)
Feed timestamp: 1789565987800000 us (2026-09-16T13:39:47.800Z)

✅ Demo complete.
```

## Failure modes

The client decodes the contract's error codes and leads with what they mean:

- **`PriceStale` (#1)** — the update lagged ledger time by more than the contract's
`freshness_threshold_us`. Fetch a new update.
- **`PriceOutdated` (#9)** — the stored price is at least as new as this update. `update_price` is
strictly monotonic, so a replayed payload always fails. The client fetches a fresh update on every
run, so re-running is safe.
- **`FeedMissing` (#2)** — `--feed-id` does not match the feed the contract was deployed with.
- **`TimestampMissing` / `PriceMissing` / `ExponentMissing` (#3 / #4 / #5)** — a required property
was absent from the payload. The client always requests all three, so this only shows up against
a payload produced elsewhere.

## Development

```bash
npm run test # prettier --check + tsc --noEmit
npm run fix:format # prettier --write
```
Loading
Loading