Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

@koed_jang/apyx-sdk

A compact viem-based TypeScript SDK and CLI for the Apyx protocol — apxUSD (yield-bearing stablecoin) and apyUSD (ERC-4626 vault) on Ethereum and Base.

This book is the user manual. It covers everything you need to read on-chain state, send transactions, run the CLI, and integrate the SDK into a frontend or a backend script.

What you’ll find here

  • Getting Started — install the package and read your first value.
  • SDK Reference — every public export from @koed_jang/apyx-sdk.
  • CLI Referenceapyx command, configuration, REPL, and per-command pages for all 21 contract methods.
  • Recipes — copy-pasteable end-to-end walkthroughs.
  • Playground — the in-browser test harness in example/.
  • Development — repo layout, testing, and release workflow.

Status

Prototype. The package is published on npm under the prototype dist-tag:

pnpm add @koed_jang/apyx-sdk@prototype viem

Public API surface and contract addresses may change before the first stable release. See the Releasing chapter for the versioning policy.

Hosting

This site lives at https://dead-pool-aka-wilson.github.io/apyx-sdk-docs/ and is published from a separate public repo (apyx-sdk-docs) because the SDK source repo is private and GitHub Pages does not serve from private repos on the free tier.

Source

The book is generated from Markdown in book/src/ in the private SDK repo. Every page has an “Edit this page” link that opens the source on GitHub.

Install

Install @koed_jang/apyx-sdk and its viem peer dependency. The SDK is distributed under the prototype dist-tag on npm — pin to it explicitly so that an unstable change can never silently roll into your build.

Requirements

  • Node ≥ 20. The SDK ships dual ESM (browser-targeted) and CJS (Node) bundles; both require an evergreen runtime.
  • viem ≥ 2.21.0, installed as a sibling. The SDK never bundles viem — you bring your own version so transports, chains, and accounts are shared with the rest of your app.

With pnpm

pnpm add @koed_jang/apyx-sdk@prototype viem

With npm

npm install @koed_jang/apyx-sdk@prototype viem

With yarn

yarn add @koed_jang/apyx-sdk@prototype viem

Without installing — dlx / npx

The CLI can be invoked one-off via your package manager’s runner without adding the package as a dependency:

pnpm dlx @koed_jang/apyx-sdk@prototype apyx --help
# or
npx -y @koed_jang/apyx-sdk@prototype apyx --help

This is the recommended path for ad-hoc shell sessions; install globally only if you reach for apyx daily.

Optional: Ledger hardware-wallet support

Hardware-wallet signing is an optional feature, and the underlying USB-HID stack pulls a couple of hundred KB of native bindings that most consumers don’t need. The SDK lists them under optionalDependencies, so a stock install does not pull them in. To enable Ledger:

pnpm add @ledgerhq/hw-transport-node-hid @ledgerhq/hw-app-eth

Without those packages installed, any signer.type = "ledger" config profile fails with a clear “install them with…” hint at the first attempt to use it. Pure read-only and key-file workflows are unaffected.

See the Ledger Setup chapter for permissions, udev rules on Linux, and the Ledger Live → derivation-path mapping.

Why prototype and not latest?

@koed_jang/apyx-sdk is being shipped as an early prototype. The latest dist-tag will be reserved for the first stabilised release; until then every published version carries the prototype tag and a -prototype.N semver suffix. That gives you two guarantees:

  1. pnpm add @koed_jang/apyx-sdk@prototype always pulls the newest prototype build, never accidentally a future stable.
  2. pnpm add @koed_jang/apyx-sdk (no tag) intentionally fails until a stable lands, so you can’t half-upgrade by mistake.

See Releasing for the full version policy.

Verify the install

The fastest sanity check is the CLI:

pnpm exec apyx --version
# 0.1.0-prototype.0   (or whatever you just installed)

A working apyx --version confirms the install + dist + binstub paths end-to-end. From there, Quickstart walks the first read against mainnet.

Quickstart

Read the live exchange rate of apyUSD on Ethereum mainnet in five lines. Then layer in writes (approve + deposit) once you’ve signed in with an account.

A read-only client

createApyxClient is the single entry point. With just a chain and a transport, you get every read method on apxUSD, apyUSD, and apyUSDRateView:

import { createApyxClient } from '@koed_jang/apyx-sdk';
import { http } from 'viem';
import { mainnet } from 'viem/chains';

const apyx = createApyxClient({
  chain: mainnet,
  transport: http(process.env.ETH_RPC_URL),
});

// apyUSD (ERC-4626 vault) reads
const exchangeRate = await apyx.apyUSD.exchangeRate();   // 1e18-scaled: 1 share -> assets
const totalAssets  = await apyx.apyUSD.totalAssets();
const shares       = await apyx.apyUSD.convertToShares(1_000_000n);

// On-chain rate view
const apy        = await apyx.apyUSDRateView?.apy();           // 18-dec fraction
const annualized = await apyx.apyUSDRateView?.annualizedYield();

// apxUSD (ERC-20) reads
const balance = await apyx.apxUSD.balanceOf('0x...');

apyUSDRateView is optional-by-chain — it exists on Ethereum but not on Base — so it’s typed apyUSDRateView?: and you guard with ?.. See Supported Chains for the full address matrix.

If process.env.ETH_RPC_URL is unset, the default public RPC kicks in. For real workloads pass an Alchemy / Infura / private endpoint — see the Custom RPC recipe.

Adding writes

To send transactions, pass an account at construction. Only then are approve, transfer, deposit, redeem, and permit exposed:

import { privateKeyToAccount } from 'viem/accounts';

const apyx = createApyxClient({
  chain: mainnet,
  transport: http(process.env.ETH_RPC_URL),
  account: privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`),
});

const approve = await apyx.apxUSD.approve({
  spender: apyx.addresses.apyUSD,
  amount: 1_000_000n,
});
await approve.wait();

const deposit = await apyx.apyUSD.deposit({
  assets: 1_000_000n,
  receiver: apyx.walletClient!.account!.address,
});
await deposit.wait();

Calling a write without account throws WalletClientRequiredError. Construct the client once, fork it for read vs write contexts:

const reader = createApyxClient({ chain: mainnet, transport: http() });
const writer = createApyxClient({ chain: mainnet, transport: http(), account });

The two clients hit the same RPC; only the wallet-side primitives differ.

What returns what

Read methods resolve directly to a value. Write methods resolve to an intermediate “tx handle” you can .wait() on:

const tx = await apyx.apyUSD.deposit({ assets, receiver });
//    ^^^^ TxHandle: { hash, wait, transactionHash }
const receipt = await tx.wait();
//    ^^^^^^^ viem's TransactionReceipt

This avoids hidden round-trips: you can fan out multiple submissions and await receipts in parallel, log the hash to a UI immediately, etc.

Where to next

Supported Chains

Two chains ship as built-in: Ethereum mainnet (chainId 1) and Base mainnet (chainId 8453). Every other chainId throws UnsupportedChainError at createApyxClient time — the SDK fails fast rather than constructing a half-broken client.

Address book

The same table lives in code at src/addresses.ts — that file is the source of truth and the registry the SDK reads at runtime. If a chain ever goes missing here but exists in the source, file an issue.

Address checksumming and immutability

createApyxClient runs every address — built-in and overridden alike — through getAddress from viem. That serves two purposes:

  1. Validation. A malformed address (non-hex, wrong length, bad case-checksum) raises InvalidAddressError at construction. Failure is loud and immediate, not at first call.
  2. Normalization. The returned apyx.addresses object always holds EIP-55 checksummed strings, regardless of the case you pass in.

The result is also Object.freezed — apyx.addresses cannot be mutated in place after construction, even by accident.

apyUSDRateView is optional per chain

The on-chain rate view contract is deployed on Ethereum but not on Base. The SDK reflects that in the type system: apyx.apyUSDRateView is typed as RateViewModule | undefined. Always guard with ?.:

const apy = await apyx.apyUSDRateView?.apy();
//                                    ^^^^^^^^
// undefined on chains where the rate view isn't deployed

Calling without the optional chain on Base would be a compile-time error.

If you need to compute the same value off-chain (e.g. to compare), combine apyUSD.exchangeRate() with a historical reference rate; see the APY recipe.

Overriding addresses (forks, testnets, custom deployments)

The built-in registry covers the live mainnet deployments. To target a fork or a custom redeploy, pass addresses at construction:

const apyx = createApyxClient({
  chain: mainnet,
  transport: http('http://127.0.0.1:8545'),
  addresses: {
    apyUSD: '0xYourFork...',
    // apxUSD + apyUSDRateView fall back to the built-in mainnet values
  },
});

A partial override is merged onto the built-in registry — you only need to specify the fields you’re moving. Each override goes through the same getAddress validation and freezes into apyx.addresses like any other entry.

This is the path used by every e2e test that runs against an anvil mainnet fork (see E2E testing).

Adding a new chain

The matrix above is short on purpose: more deployments lands as the protocol expands. Track the add list in src/addresses.ts — a new chainId entry there ripples through to the SDK and CLI without code changes elsewhere.

If you’re consuming the SDK on a chain we haven’t listed and the contract is already deployed there, override addresses and tell us — we’d rather generalise the registry than have every consumer hand-roll the same mapping.

SDK Overview

@koed_jang/apyx-sdk is a thin wrapper around viem. The whole package boils down to one factory function — createApyxClient — that returns an object with three contract modules and a few utility handles.

The shape of an ApyxClient

interface ApyxClient {
  // viem primitives, exposed for escape hatches
  chain: Chain;
  publicClient: PublicClient;
  walletClient?: WalletClient;          // present only when `account` was passed
  addresses: Readonly<ApyxAddresses>;   // EIP-55 normalized + Object.freeze'd

  // protocol modules
  apxUSD: ApxUSD;                       // ERC-20 + permit
  apyUSD: ApyUSD;                       // ERC-4626 vault wrapping apxUSD
  apyUSDRateView?: ApyUSDRateView;      // optional per chain
}

Module map

flowchart LR
  cfg[ApyxClientConfig\nchain + transport + ?account] --> factory(createApyxClient)
  factory --> client[ApyxClient]
  client --> apx[apxUSD\nERC-20]
  client --> apy[apyUSD\nERC-4626]
  client --> rv[apyUSDRateView\nyield view]
  client --> pub[publicClient]
  client --> wal[walletClient?]
  factory -. throws .-> e1{{UnsupportedChainError}}
  factory -. throws .-> e2{{InvalidAddressError}}
  apx -. throws on writes without account .-> e3{{WalletClientRequiredError}}
  apy -. throws on writes without account .-> e3

Public exports at a glance

Source of truth: src/index.ts.

ExportKindPage
createApyxClientfactory functionclient
ApyxClient, ApyxClientConfig, ApyxClientCoretypesclient
getAddresses, APYX_ADDRESSES, ApyxAddressesfunction + constant + typeaddresses
UnsupportedChainError, WalletClientRequiredError, InvalidAddressErrorerror classeserrors
ApxUSDAbi, ApyUSDAbi, ApyUSDRateViewAbityped as const ABI arraysabis

The apxUSD, apyUSD, and apyUSDRateView modules aren’t separately exported — they’re attached to the ApyxClient object you get back from the factory. Their per-method reference lives in apxUSD, apyUSD, and apyUSDRateView.

Read-only vs write-enabled

createApyxClient always returns a fully-functional read client. Pass account to additionally enable writes:

Pass account?walletClientreadswrites
noundefinedthrow WalletClientRequiredError
yesWalletClient

This split is intentional — every read-only consumer (UIs, indexers, dashboards) gets the smallest possible surface, and write paths fail fast at first call rather than silently constructing tx hashes against a phantom signer.

Where to next

  • createApyxClient — every config option, all three error throwing-points, the walletTransport escape hatch.
  • apxUSD, apyUSD, apyUSDRateView — per-method reference.
  • Errors — the three thrown error classes and how to recover.
  • AddressesAPYX_ADDRESSES registry, override semantics, EIP-55 normalization.
  • ABIs — raw ABI exports for use with viem directly.

createApyxClient

The single factory function. Pass a chain and a transport; receive a fully-typed client whose three contract modules know exactly which addresses to call.

import { createApyxClient } from '@koed_jang/apyx-sdk';
import { http } from 'viem';
import { mainnet } from 'viem/chains';

const apyx = createApyxClient({ chain: mainnet, transport: http() });

Signature

function createApyxClient(config: ApyxClientConfig): ApyxClient;
type ApyxClientConfig = {
  chain: Chain;
  transport: Transport;
  walletTransport?: Transport;
  account?: Account | Address;
  addresses?: Partial<ApyxAddresses>;
};
FieldRequiredEffect
chainyesviem Chain object. Drives chainId-based address lookup, gas estimation, and EIP-1559 vs legacy tx defaults.
transportyesviem Transport. Used for reads by the underlying publicClient, and for writes too unless walletTransport is set. Typically http(rpcUrl).
walletTransportnoSeparate transport for writes. Set this to custom(window.ethereum) in dApps while keeping transport: http(rpc) for reads — gives you a low-latency public RPC for state and the user’s wallet for signing.
accountnoEither a viem Account (e.g. privateKeyToAccount(...), a hardware-wallet account from a custom toAccount) or a bare Address. When omitted no walletClient is created and write methods throw WalletClientRequiredError.
addressesnoPartial<ApyxAddresses> overlay. Useful for forks, custom redeploys, or chains where you want to point the SDK at a non-canonical address. Each field is independently optional and falls back to the built-in registry; see Addresses for the merge semantics.

Return type

interface ApyxClient {
  chain: Chain;
  addresses: Readonly<ApyxAddresses>;
  publicClient: PublicClient;
  walletClient?: WalletClient;

  apxUSD: ApxUSD;
  apyUSD: ApyUSD;
  apyUSDRateView?: ApyUSDRateView;
}

interface ApyxClientCore {
  chain: Chain;
  addresses: Readonly<ApyxAddresses>;
  publicClient: PublicClient;
  walletClient?: WalletClient;
}

ApyxClientCore is the subset of ApyxClient that contract modules receive at construction. It’s exported because consumers occasionally build their own helpers that take a “client core” — pass it { chain, addresses, publicClient, walletClient } and you can construct your own apxUSD/apyUSD instances against the same fields.

What the factory does

  1. Resolves addresses. Looks up chain.id in APYX_ADDRESSES; throws UnsupportedChainError for any unknown chainId.
  2. Merges overrides. {...builtin, ...config.addresses}.
  3. Validates and freezes addresses. Each non-null entry runs through viem.isAddress(value, { strict: true }). Failure raises InvalidAddressError. The result is Object.freezed so apyx.addresses cannot be mutated post-construction.
  4. Builds clients. A publicClient is always built. A walletClient is built only when account is non-null.
  5. Wires modules. apxUSD(core), apyUSD(core), and (if the chain has a apyUSDRateView address) apyUSDRateView(core, address).
sequenceDiagram
  participant U as Caller
  participant F as createApyxClient
  participant V as viem
  U->>F: { chain, transport, account?, addresses? }
  F->>F: lookup APYX_ADDRESSES[chain.id]
  alt unknown chain
    F-->>U: throw UnsupportedChainError
  end
  F->>F: merge overrides
  F->>V: isAddress(strict) for each field
  alt malformed
    F-->>U: throw InvalidAddressError
  end
  F->>F: Object.freeze(addresses)
  F->>V: createPublicClient(chain, transport)
  alt account passed
    F->>V: createWalletClient(chain, walletTransport ?? transport, account)
  end
  F->>F: wire apxUSD / apyUSD / apyUSDRateView modules
  F-->>U: ApyxClient

Errors thrown at construction

ErrorWhenHow to recover
UnsupportedChainErrorchain.id is not in the built-in registryEither pass a supported chain (Ethereum or Base) or override addresses with the redeploy targets.
InvalidAddressErrorA built-in or overridden address fails viem.isAddress({ strict: true })Provide a properly checksummed (or all-lowercase) hex address of the right length.

Errors are thrown synchronously from createApyxClient itself — there is no async construction path. A successfully returned ApyxClient is guaranteed to have valid, frozen addresses.

Common patterns

Read-only client for a UI

const apyx = createApyxClient({
  chain: mainnet,
  transport: http(import.meta.env.VITE_ETH_RPC_URL),
});

Browser dApp — public RPC + injected wallet

const apyx = createApyxClient({
  chain: mainnet,
  transport: http(rpcUrl),                  // reads via Alchemy / etc
  walletTransport: custom(window.ethereum), // writes via the user's wallet
  account: userAddress,                     // populated after eth_requestAccounts
});

Server-side worker with a private key

const apyx = createApyxClient({
  chain: mainnet,
  transport: http(process.env.RPC_URL),
  account: privateKeyToAccount(process.env.PK as `0x${string}`),
});

Anvil fork test

const apyx = createApyxClient({
  chain: mainnet,
  transport: http('http://127.0.0.1:8545'),
  account: testAccount,
  addresses: {
    apyUSD: '0xRedeployedFork...',          // partial override
  },
});

apxUSD module

ERC-20 yield-bearing stablecoin that the apyUSD vault wraps. Available as apyx.apxUSD after createApyxClient.

const balance = await apyx.apxUSD.balanceOf('0xabc…');
const tx      = await apyx.apxUSD.approve({ spender, amount });

Address

apyx.apxUSD.address
//          ^^^^^^^^ Address — the apxUSD contract on the configured chain

Always equals apyx.addresses.apxUSD. Pinned to the built-in registry unless you passed addresses.apxUSD as an override.

Reads

All reads return Promise<T> and route through apyx.publicClient.readContract.

MethodReturnsNotes
balanceOf(owner: Address)bigintRaw token balance, 18-decimal scaled.
allowance(owner, spender)bigintERC-20 allowance.
totalSupply()bigintTotal minted apxUSD.
supplyCap()bigintMax permitted supply (protocol guardrail).
supplyCapRemaining()bigintHeadroom = supplyCap - totalSupply.
paused()booleantrue if the contract is paused (writes will revert).
decimals()numberAlways 6 today, but read it rather than hardcode.
nonces(owner: Address)bigintEIP-2612 nonce. Used when signing permit.
const [balance, allowance, supply, paused] = await Promise.all([
  apyx.apxUSD.balanceOf(user),
  apyx.apxUSD.allowance(user, apyx.addresses.apyUSD),
  apyx.apxUSD.totalSupply(),
  apyx.apxUSD.paused(),
]);

Writes

All writes require account to have been passed at createApyxClient time. Without it, every write throws WalletClientRequiredError before any RPC traffic.

Each write returns a TxHandle:

type TxHandle = {
  hash: `0x${string}`;
  wait: () => Promise<TransactionReceipt>;
};

wait() resolves the receipt via publicClient.waitForTransactionReceipt.

transfer({ to, amount })

ERC-20 transfer to to.

const tx = await apyx.apxUSD.transfer({ to: '0x…', amount: 1_000_000n });
const receipt = await tx.wait();

approve({ spender, amount })

ERC-20 approval. The most common spender is apyx.addresses.apyUSD — required before apyUSD.deposit can pull funds.

const tx = await apyx.apxUSD.approve({
  spender: apyx.addresses.apyUSD,
  amount:  1_000_000n,           // 1 apxUSD at 6 decimals
});
await tx.wait();

permit({ owner, spender, value, deadline, v, r, s })

EIP-2612 gasless approval. The signature comes from a separate off-chain signing step (typically walletClient.signTypedData against the permit domain). When you submit permit, you pay the gas to land the approval; the holder pays nothing.

const tx = await apyx.apxUSD.permit({
  owner, spender, value, deadline,
  v, r, s,                       // returned by your typed-data signing step
});
await tx.wait();

For nonce + domain construction, see the approve recipe.

Source

src/contracts/apxUSD.ts is the implementation. The shape above is generated ReturnType<typeof apxUSD> — if it ever drifts from this page, the source is authoritative.

apyUSD module

ERC-4626 vault wrapping apxUSD. Shares (apyUSD) are 18-decimal, underlying assets (apxUSD) are 6-decimal. Available as apyx.apyUSD after createApyxClient.

const rate   = await apyx.apyUSD.exchangeRate();   // 1 share -> assets, 1e18 scaled
const shares = await apyx.apyUSD.convertToShares(1_000_000n);

Address

apyx.apyUSD.address

Equals apyx.addresses.apyUSD.

Reads

Conversion views

MethodReturnsNotes
asset()AddressUnderlying asset (always apxUSD).
totalAssets()bigintVault TVL in apxUSD (6-decimal).
convertToShares(assets: bigint)bigintapxUSD → apyUSD at the current ratio.
convertToAssets(shares: bigint)bigintapyUSD → apxUSD.
exchangeRate()bigintConvenience: convertToAssets(1e18). 18-decimal scaled. The single most-read number on this contract — the value displayed by every UI / dashboard.

Preview views

Pre-flight quotes for the four ERC-4626 mutations. These match exactly what would happen on-chain at the current block, including any rounding.

MethodReturns
previewDeposit(assets)shares minted for assets
previewMint(shares)assets pulled to mint shares
previewWithdraw(assets)shares burned for assets
previewRedeem(shares)assets returned for shares

Capacity views

MethodReturns
maxDeposit(receiver)max apxUSD receiver can deposit
maxMint(receiver)max apyUSD shares receiver can mint
maxRedeem(owner)max apyUSD owner can redeem
maxWithdraw(owner)max apxUSD owner can withdraw

Holder views

MethodReturnsNotes
balanceOf(owner)bigintapyUSD share balance, 18-decimal.
decimals()numberAlways 18, but read it rather than hardcode.

Writes

All writes require account; without it they throw WalletClientRequiredError. Each write returns a TxHandle = { hash, wait }.

Approval first. deposit and mint pull apxUSD from the sender via transferFrom. You must approve the apyUSD address first — see the approve and deposit recipe.

deposit({ assets, receiver })

Pull assets apxUSD from the sender, mint shares to receiver.

const tx = await apyx.apyUSD.deposit({
  assets:   1_000_000n,                         // 1 apxUSD (6-dec)
  receiver: apyx.walletClient!.account!.address,
});
await tx.wait();

mint({ shares, receiver })

Mint exactly shares apyUSD to receiver, pulling whatever apxUSD is required (previewMint(shares)).

withdraw({ assets, receiver, owner })

Burn shares from owner to deliver assets apxUSD to receiver. Requires that owner === wallet.account or that the wallet has been approved by owner (approve is for the share token in this case).

redeem({ shares, receiver, owner })

Burn exactly shares apyUSD from owner, deliver previewRedeem(shares) apxUSD to receiver. The most common exit path — see the redeem recipe.

const tx = await apyx.apyUSD.redeem({
  shares:   apyx.walletClient!.account!.address,  // your share balance
  receiver: apyx.walletClient!.account!.address,
  owner:    apyx.walletClient!.account!.address,
});

Source

src/contracts/apyUSD.ts. The exposed shape is ReturnType<typeof apyUSD>; this page mirrors that and the source wins on conflict.

apyUSDRateView module

On-chain APY view for the apyUSD vault. Read-only — no writes, nothing to sign. Available on Ethereum mainnet but not on Base, so the field is typed apyx.apyUSDRateView?: ApyUSDRateView | undefined.

if (apyx.apyUSDRateView) {
  const apy = await apyx.apyUSDRateView.apy();   // 18-decimal fraction
}

Why it’s optional

The address registry deliberately omits apyUSDRateView for chains where the contract isn’t deployed. createApyxClient reflects that:

type ApyxAddresses = {
  apxUSD: Address;
  apyUSD: Address;
  apyUSDRateView?: Address;     // optional
};

When the address is missing, the factory does not instantiate the module at all — apyx.apyUSDRateView is left undefined. Always guard with optional chaining (apyx.apyUSDRateView?.apy()) so the same code compiles and runs cleanly across Ethereum and Base.

const annualized = (await apyx.apyUSDRateView?.annualizedYield()) ?? 0n;

Methods

MethodReturnsNotes
apy()bigintCurrent APY as an 18-decimal fraction. 1.05e18 = 5% APY. Format with viem.formatUnits(apy, 18) for a decimal string.
annualizedYield()bigintAnnualised yield, same 18-decimal scaling. Equivalent to compounding apy() over a year. UIs often display this alongside (or instead of) raw APY.
precision()bigintThe contract’s internal precision base (e.g. 1e18). Useful when you want to do percentage math without trusting the format.
vault()AddressAddress of the apyUSD vault this rate view points at. Should equal apyx.addresses.apyUSD; if not, you’ve configured a mismatched override.

Address

apyx.apyUSDRateView?.address

Equals apyx.addresses.apyUSDRateView when the module exists.

Cross-checking against the vault

The rate view derives APY from apyUSD.exchangeRate() over a window. A useful sanity check when debugging:

const [apy, ann, precision, vault, exchangeRate] = await Promise.all([
  apyx.apyUSDRateView?.apy(),
  apyx.apyUSDRateView?.annualizedYield(),
  apyx.apyUSDRateView?.precision(),
  apyx.apyUSDRateView?.vault(),
  apyx.apyUSD.exchangeRate(),
]);

console.assert(vault === apyx.addresses.apyUSD, 'rate view points elsewhere');

If vault !== apyx.addresses.apyUSD, your override map is internally inconsistent — fix the addresses.apyUSDRateView override or remove it.

Source

src/contracts/apyUSDRateView.ts.

Errors

The SDK throws three distinct error classes. They are exhaustive — any exception you catch from an ApyxClient either belongs to one of these or is a viem / RPC-level error passed through unmodified.

import {
  UnsupportedChainError,
  WalletClientRequiredError,
  InvalidAddressError,
} from '@koed_jang/apyx-sdk';

UnsupportedChainError

Thrown by: createApyxClient, getAddresses(chainId).

When: the chain.id you passed isn’t in the built-in APYX_ADDRESSES registry.

Message:

chainId <id> is not supported by @apyx-labs/sdk

How to recover:

  • Pass a supported chain (mainnet, base).

  • Or, if you’ve redeployed the contracts to a new chain, override addresses at construction:

    createApyxClient({
      chain: someOtherChain,
      transport: http(),
      addresses: {
        apxUSD: '0x…',
        apyUSD: '0x…',
        apyUSDRateView: '0x…',  // optional
      },
    });
    

    Note that addresses is a Partial<ApyxAddresses>, but if the chain isn’t in the built-in registry there’s nothing to merge with — you must supply the full triple (or omit apyUSDRateView if it isn’t deployed there).

WalletClientRequiredError

Thrown by: any write method on apxUSD or apyUSDapprove, transfer, permit, deposit, mint, withdraw, redeem.

When: you constructed the client without account, so no walletClient was created, then called a method that needs one.

Message:

apxUSD.approve requires a wallet client. Pass `account` when calling createApyxClient.

(The leading apxUSD.approve is the action — it changes per call site so log lines pinpoint the specific method.)

How to recover:

Pass account at construction:

const apyx = createApyxClient({
  chain: mainnet,
  transport: http(),
  account: privateKeyToAccount(process.env.PK as `0x${string}`),
});

If you want a single client that does both reads (no signing) and sometimes-writes, that’s still the pattern — apyx.publicClient is fine to use for reads even when walletClient is set. If you don’t want write capability at all (e.g. a UI that explicitly forbids unsigned tx attempts), this error is the runtime guard that catches mis-wired components.

This error is thrown synchronously from inside the write method, before any RPC traffic — there’s no risk of constructing or sending a tx without a signer.

InvalidAddressError

Thrown by: createApyxClient.

When: any address — built-in or overridden — fails viem.isAddress(value, { strict: true }). Strict mode requires:

  • 0x prefix
  • Exactly 40 hex characters
  • All lowercase or correctly EIP-55 mixed-case-checksummed

Message:

Invalid address for `addresses.apyUSD`: <bad value>

The leading addresses.apyUSD is the field — addresses.apxUSD, addresses.apyUSD, addresses.apyUSDRateView are the only three possible.

How to recover:

  • Drop garbage characters and ensure exactly 42 chars (0x + 40 hex).
  • If you copied an address by hand, run it through viem.getAddress(...) first to checksum it.
  • Or pass it all-lowercase: '0x…'.toLowerCase() is also accepted.

After construction, apyx.addresses is always EIP-55 checksummed and Object.freezed — you can pass any valid form in, but you read out a canonical normalized form.

What’s not thrown by the SDK

The SDK is intentionally thin on top of viem. Anything that isn’t domain-specific (RPC errors, contract reverts, gas estimation failures, chain-id mismatches at submission time, replay protection) bubbles up as a viem BaseError subclass. Catch those at the call site if you need to: most consumers find the rich viem error chain (error.cause, error.shortMessage, error.metaMessages) sufficient without further SDK-level abstraction.

Addresses

The address registry, the lookup function, and the type. For the human-readable matrix and Etherscan/Basescan links, see Supported Chains.

import { APYX_ADDRESSES, getAddresses, type ApyxAddresses } from '@koed_jang/apyx-sdk';

ApyxAddresses

type ApyxAddresses = {
  apxUSD: Address;
  apyUSD: Address;
  apyUSDRateView?: Address;
};

apxUSD and apyUSD are required; apyUSDRateView is optional — chains where the rate-view contract isn’t deployed simply omit the field.

APYX_ADDRESSES

The built-in registry, keyed by chainId:

const APYX_ADDRESSES: Record<number, ApyxAddresses> = {
  1: {
    apxUSD: '0x98A878b1Cd98131B271883B390f68D2c90674665',
    apyUSD: '0x38EEb52F0771140d10c4E9A9a72349A329Fe8a6A',
    apyUSDRateView: '0xab3Aa53D942cbFb58773856BdE4F3c3EFbaf0fDc',
  },
  8453: {
    apxUSD: '0xD993935E13851dd7517af10687EC7e5022127228',
    apyUSD: '0x2c271ddF484aC0386d216eB7eB9Ff02D4Dc0F6AA',
  },
};

This constant is not frozen when imported — if you absolutely need to mutate it (e.g. injecting a fork chain in a test harness), you can. Most consumers should prefer addresses overrides on createApyxClient instead, which leaves the registry untouched.

getAddresses(chainId)

The lookup helper. The same call createApyxClient makes internally.

function getAddresses(chainId: number): ApyxAddresses;
Behaviour
Known chainIdreturns the entry from APYX_ADDRESSES.
Unknown chainIdthrows UnsupportedChainError.

Useful when you want to read addresses without constructing a full client (e.g. logging a startup banner).

Override semantics

createApyxClient({
  chain: mainnet,
  transport: http(),
  addresses: { apyUSD: '0xFork…' },
});

The addresses field is Partial<ApyxAddresses>. The factory does:

const merged = { ...APYX_ADDRESSES[chain.id], ...overrides };

so you can override one field, two fields, or all three. The merge respects undefined: passing addresses: { apyUSDRateView: undefined } does not strip the field — use addresses: {} (or omit the key) to keep the built-in value, and pass an explicit address only when you want to replace it.

Validation and freezing

After merge, createApyxClient runs each non-null field through viem.isAddress(value, { strict: true }):

Validation ruleEffect
Must begin with 0xelse InvalidAddressError
Must be exactly 42 chars (0x + 40 hex)else InvalidAddressError
Either all-lowercase or correctly EIP-55 mixed-caseelse InvalidAddressError

The validated ApyxAddresses is then Object.freezed. The result exposed as apyx.addresses is therefore:

  • Guaranteed valid (no further validation needed downstream).
  • Guaranteed checksummed (read-out form is canonical regardless of input case).
  • Guaranteed immutable (mutation attempts silently fail in non-strict mode, throw under 'use strict').

Consumers in code

Source: src/addresses.ts. The CLI’s config respects address overrides via three flags: --address-apxusd, --address-apyusd, --address-rate-view. They map 1:1 onto this object’s three fields.

ABIs

Typed as const ABI arrays for the three Apyx contracts, exported for use with viem directly.

import { ApxUSDAbi, ApyUSDAbi, ApyUSDRateViewAbi } from '@koed_jang/apyx-sdk';

When to reach for the raw ABIs

The first-class API on apyx.apxUSD / apyx.apyUSD / apyx.apyUSDRateView covers every method shipped on these contracts. Reach for the raw ABIs when you want one of:

  • viem’s typed contract instances for advanced patterns:

    import { getContract } from 'viem';
    const apx = getContract({
      address: apyx.addresses.apxUSD,
      abi: ApxUSDAbi,
      client: apyx.publicClient,
    });
    
  • Multicall batching through publicClient.multicall, where you pass { address, abi, functionName, args } items directly.

  • Event log decoding:

    import { decodeEventLog } from 'viem';
    const event = decodeEventLog({
      abi: ApyUSDAbi,
      data: log.data,
      topics: log.topics,
    });
    
  • Permit / typed-data hashing that needs the EIP-712 domain separator constants embedded in the ABI’s permit definition.

For everything else, the typed module wrappers are a shorter path.

Available ABIs

ExportContractWhat it covers
ApxUSDAbiapxUSDERC-20 + EIP-2612 permit + supply cap views + pausability.
ApyUSDAbiapyUSDFull ERC-4626 (deposit/mint/withdraw/redeem + their previews + max-views) plus the share-token ERC-20 surface.
ApyUSDRateViewAbiapyUSDRateViewapy(), annualizedYield(), precision(), vault().

Each is a readonly tuple typed as const, so passing it to viem yields full type-narrowing on functionName, args, and the return.

Source provenance

JSON sources under abis/ in this repo are vendored from apyx-labs/subquery at commit 651ba2c9f8d971547828b206f73008c850009028. The typed as const wrappers are generated from the JSON by pnpm gen:abis (a tsx script under scripts/gen-abis.ts). After updating the JSON files, regenerate:

pnpm gen:abis

The generated wrappers land in src/generated/ and are committed — consumers don’t need to run the generator themselves.

CLI Install

The apyx binary ships in the same package as the SDK (@koed_jang/apyx-sdk). Install the package and you have the CLI.

pnpm add -g @koed_jang/apyx-sdk@prototype
apyx --version

Three install styles

Project-local

Add to your project’s devDependencies and run via your package manager’s exec command. Best when the CLI is part of a workflow you share with collaborators.

pnpm add -D @koed_jang/apyx-sdk@prototype viem
pnpm exec apyx --help

Global

Install once, run anywhere. Best for daily ad-hoc use on a personal workstation.

pnpm add -g @koed_jang/apyx-sdk@prototype
apyx --help

Without installing — pnpm dlx / npx

Runs the CLI with the package fetched into a one-shot cache. Best when you want to try a specific version without committing to it.

pnpm dlx @koed_jang/apyx-sdk@prototype apyx --help
# or
npx -y @koed_jang/apyx-sdk@prototype apyx --help

Verify

$ apyx --version
0.1.0-prototype.0

A working apyx --version confirms the install + dist + binstub paths end-to-end. From there:

Optional: Ledger

Hardware-wallet support is opt-in to keep the default install lean. Install the optional native deps separately when you want it:

pnpm add @ledgerhq/hw-transport-node-hid @ledgerhq/hw-app-eth

See the Ledger Setup chapter for permissions, udev rules on Linux, and how to wire a Ledger profile into your config.

Requirements

  • Node ≥ 20. The CLI is a tiny CommonJS bin that loads the SDK’s Node bundle.
  • A working PATH entry for your global node_modules/.bin if you installed globally (pnpm/yarn/npm all do this automatically; if apyx isn’t found, run pnpm setup or your manager’s equivalent).

Updating

pnpm update -g @koed_jang/apyx-sdk@prototype

The prototype dist-tag always points at the latest pre-release. Pin to a specific version (e.g. @0.1.0-prototype.0) when you want reproducibility across machines.

Configuration

The CLI reads a JSON config file that defines named profiles — each profile binds a chain, an RPC URL, and (optionally) a signer. The merged config feeds every apyx repl and every per-method subcommand.

{
  "defaultProfile": "default",
  "profiles": {
    "default": {
      "chain": "ethereum",
      "rpcUrl": "https://eth.llamarpc.com",
      "signer": { "type": "key", "keyPath": "~/.apyx/keys/default" }
    }
  }
}

Schema

type ApyxConfig = {
  defaultProfile?: string;
  profiles: Record<string, Profile>;
};

type Profile = {
  chain: 'ethereum' | 'base';
  rpcUrl: string;
  signer?: Signer;
};

type Signer =
  | { type: 'key'; keyPath: string }
  | { type: 'ledger'; derivationPath: string };
FieldRequiredNotes
defaultProfilenoName of the profile to use when no --profile flag is passed. Must match a key in profiles.
profilesyesAt least one entry — apyx config init creates default.
profile.chainyes"ethereum" or "base". Drives chainId-based address lookup in APYX_ADDRESSES.
profile.rpcUrlyesNon-empty string. Public endpoints work for reads; for writes/heavy reads use a paid RPC.
profile.signernoWhen omitted, the profile is read-only — apyx repl boots without an account, and writes throw WalletClientRequiredError.
signer.type = "key"Local-key signing. keyPath points at a hex private-key file. The key itself is never written to the config.
signer.type = "ledger"Hardware-wallet signing via @ledgerhq/hw-app-eth. derivationPath is the BIP-32 path, e.g. "m/44'/60'/0'/0/0". See Ledger Setup.

The full validator (with exact error messages) lives in src/cli/config.ts.

Resolution rules

flowchart LR
  A[CLI invocation] --> B{cwd has<br/>./apyx.config.json?}
  B -->|yes| C[parse project config]
  B -->|no| D[skip project]
  C --> E{home config<br/>exists?}
  D --> E
  E -->|yes| F[parse ~/.apyx/config.json<br/>or $XDG_CONFIG_HOME/apyx/config.json]
  E -->|no| G[skip home]
  F --> H[merge: project profiles<br/>fully replace home profiles<br/>of the same name]
  G --> H
  H --> I[resolve profile name<br/>via --profile or defaultProfile]
  I --> J[hand to createApyxClient]
StepDetail
Project config./apyx.config.json in the current working directory.
Home config~/.apyx/config.json, or $XDG_CONFIG_HOME/apyx/config.json when $XDG_CONFIG_HOME is set and non-empty.
MergeProject profiles fully replace home profiles of the same name (no deep merge — a project’s default swap wholly overrides the home’s default, so you can’t accidentally inherit a stale signer block).
defaultProfileProject defaultProfile wins if set; else home’s; else the first profile in iteration order.
No config anywhereMost subcommands exit 1 with No config found. Run \apyx config init`. The –versionand–help` flags work without a config.

Key files (for signer.type = "key")

The config holds a path to a key file, not the key itself.

mkdir -p ~/.apyx/keys
$EDITOR ~/.apyx/keys/default      # paste a 32-byte hex key, optional 0x prefix
chmod 600 ~/.apyx/keys/default
RuleEffect
First non-empty line is readTrailing comments / mnemonics on later lines are ignored, but cleaner files are easier to audit — keep them one-line.
0x prefix is optionalBoth 0xabcd… and abcd… are accepted.
Must decode to exactly 32 bytesOtherwise: key file does not contain hex / expected 32 bytes (64 hex chars).
Permissions are checkedIf the file is group- or world-readable, the CLI prints a non-fatal warning suggesting chmod 600 <path>. The key still loads.

Never put apyx.config.json into a public repo if its keyPath points at anything sensitive. The recommended pattern: keep ~/.apyx/keys/ outside the repo, and gitignore project-level configs that reference paths under it.

Inspecting a resolved config

The three apyx config subcommands tell you what the CLI sees:

SubcommandPage
apyx config initconfig init
apyx config showconfig show
apyx config pathconfig path

Next

  • REPL — what happens when you run apyx repl with a resolved config.
  • Session-Start Flags — overriding individual fields per-session without rewriting the config.
  • Ledger Setup — adding a signer.type = "ledger" profile.

config init

Interactive wizard. Five prompts, one written file: ./apyx.config.json.

apyx config init

Synopsis

apyx config init

No flags. The wizard runs in the current directory and writes ./apyx.config.json (the project config). Pre-existing files are detected and the wizard asks before overwriting.

What it asks

$ apyx config init
chain (ethereum | base) [ethereum]:
rpcUrl [https://eth.llamarpc.com]:
signer type (key | ledger | none) [key]:
keyPath [~/.apyx/keys/default]:
wrote /path/to/cwd/apyx.config.json
next: put your hex private key at ~/.apyx/keys/default
      (one line, 32 bytes; `chmod 600` the file).
PromptDefaultEffect
chainethereumSets profiles.default.chain.
rpcUrlhttps://eth.llamarpc.com (or Base default for Base)Sets profiles.default.rpcUrl. Use a paid RPC for any real workload — see Custom RPC recipe.
signer typekeyPicks the signer shape. none writes a profile without a signer (read-only).
keyPath~/.apyx/keys/defaultOnly asked when signer type = key. Path is stored verbatim — the wizard does not create the file.
derivationPathm/44'/60'/0'/0/0Only asked when signer type = ledger.

After writing the file the wizard prints a “next:” reminder pointing at whichever path you chose for the key.

What gets written

{
  "defaultProfile": "default",
  "profiles": {
    "default": {
      "chain": "ethereum",
      "rpcUrl": "https://eth.llamarpc.com",
      "signer": { "type": "key", "keyPath": "~/.apyx/keys/default" }
    }
  }
}

Only one profile (default). To add more, edit the file by hand or add another profile block — the schema is in Configuration.

Overwrite handling

If ./apyx.config.json already exists:

$ apyx config init
./apyx.config.json already exists. Overwrite? (y/N): n
aborted

Decline and the file is untouched, exit code 1. The decline path is covered by an e2e spec — see test/e2e/cli/config.e2e.spec.ts.

Exit codes

CodeMeaning
0Wrote a new config file
1User declined to overwrite an existing file, or wizard aborted via ^C

Common follow-ups

After config init:

  1. Drop your hex private key into the keyPath you chose:
    echo '0xYOUR_KEY' > ~/.apyx/keys/default
    chmod 600 ~/.apyx/keys/default
    
  2. Verify the resolved config: apyx config show.
  3. Open the REPL: apyx repl.

config show

Print the merged config (project ⨁ home) as pretty JSON. Use it to verify what the CLI actually sees after both files have been read and the project’s profiles have replaced any same-named home profiles.

apyx config show

Synopsis

apyx config show [--profile <name>]
FlagDefaultEffect
--profile <name>(none — print everything)Print only the named profile, exit 1 with Unknown profile if it doesn’t exist.

Examples

Whole config

$ apyx config show
{
  "defaultProfile": "default",
  "profiles": {
    "default": {
      "chain": "ethereum",
      "rpcUrl": "https://eth.llamarpc.com",
      "signer": { "type": "key", "keyPath": "~/.apyx/keys/default" }
    },
    "base": {
      "chain": "base",
      "rpcUrl": "https://mainnet.base.org",
      "signer": { "type": "key", "keyPath": "~/.apyx/keys/default" }
    }
  }
}

Single profile

$ apyx config show --profile base
{
  "chain": "base",
  "rpcUrl": "https://mainnet.base.org",
  "signer": { "type": "key", "keyPath": "~/.apyx/keys/default" }
}

No config found

$ cd /tmp && apyx config show
No config found. Run `apyx config init`.
echo $?    # 1

Unknown profile

$ apyx config show --profile staging
Unknown profile "staging". Known: default, base
echo $?    # 1

What “merged” means

The output is the result of merging ~/.apyx/config.json (or $XDG_CONFIG_HOME/apyx/config.json) and ./apyx.config.json per the rules in Configuration → Resolution rules. The merge happens in memory; no file is written. To see which file each profile came from, use apyx config path.

Exit codes

CodeMeaning
0Printed the (filtered) config
1No config found / unknown profile
2Usage error (unknown flag)

config path

Print the file paths the CLI is reading config from. Use this when something looks wrong in config show and you want to know which file is supplying which profile.

apyx config path

Synopsis

apyx config path

No flags. Prints one line per source file the CLI loaded, in order: home first, project second. A profile defined in both files is ultimately governed by the project file’s version.

Examples

Both files present

$ apyx config path
/Users/jane/.apyx/config.json (home)
/Users/jane/work/apyx-sdk/apyx.config.json (project)

Project only

$ apyx config path
/Users/jane/work/apyx-sdk/apyx.config.json (project)

$XDG_CONFIG_HOME set

$ XDG_CONFIG_HOME=/Users/jane/Library/Preferences apyx config path
/Users/jane/Library/Preferences/apyx/config.json (home)
/Users/jane/work/apyx-sdk/apyx.config.json (project)

No config found

$ cd /tmp && apyx config path
No config found. Run `apyx config init`.
echo $?    # 1

Resolution rules

See Configuration → Resolution rules. The summary:

  • ./apyx.config.json is the project config.
  • ~/.apyx/config.json (or $XDG_CONFIG_HOME/apyx/config.json) is the home config.
  • Project profiles fully replace home profiles of the same name — there’s no deep merge.

Exit codes

CodeMeaning
0Printed at least one path
1No config files found in either location
2Usage error (unknown flag)

REPL

The interactive shell. Pre-wires a constructed ApyxClient against your active profile so you can try reads, writes, and ad-hoc viem calls without writing a script.

apyx repl

The first thing the REPL prints is a context banner — read it before running anything mutating:

$ apyx repl
@apyx-labs/sdk
profile: default (from /path/to/cwd/apyx.config.json)
chain:   ethereum (1)
rpc:     https://eth.llamarpc.com
account: 0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf
LineMeaning
profile:Active profile name + which file it was read from. Says (overridden by --profile <name>) when set via flag.
chain:Human chain name + chainId.
rpc:Resolved RPC URL. Says (overridden) when --rpc-url was passed.
account:Active account address, or none for read-only profiles.

Injected globals

Every REPL session has these globals already resolved:

GlobalTypeNotes
apyxApyxClientThe full constructed client — apyx.publicClient, apyx.walletClient (if signer), apyx.addresses, apyx.apxUSD, apyx.apyUSD, apyx.apyUSDRateView?.
accountAccount | undefinedThe active viem Account, or undefined for read-only profiles.
chainChainviem’s Chain object for the active chain.
viem{ parseUnits, formatUnits, parseEther, formatEther, getAddress }Common helpers, no extra import required.
apyx> await apyx.apyUSD.exchangeRate()
1041273481200000000n

apyx> viem.formatUnits(await apyx.apyUSD.totalAssets(), 6)
'48912.43'

apyx> await apyx.apxUSD.balanceOf(account.address)
1234560000n

Top-level await is on by default. Tab completes dotted paths (apyx.apyUSD.<TAB> lists every method on the module).

Built-in commands

apyx> .help
.break   Sometimes you get stuck; this gets you out
.clear   Alias for .break
.editor  Enter editor mode
.env     Switch to another profile (.env <name>)
.exit    Exit the REPL
.help    Print this help message
.load    Load JS from a file into the REPL session
.save    Save all evaluated commands in this REPL session to a file

.env <name> swaps profiles in-place — the banner prints again, and all four globals (apyx, account, chain, viem) are reconstructed against the new profile.

apyx> .env base
@apyx-labs/sdk
profile: base (from /path/to/cwd/apyx.config.json)
chain:   base (8453)
rpc:     https://mainnet.base.org
account: 0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf

Session-start overrides do not follow .env. A --rpc-url override applies only to the initial profile; after .env <name> the new profile uses its own values from the config. Re-invoke the CLI if you want overrides on a different profile.

History

History persists across sessions at ~/.apyx/history. Up-arrow walks back, Ctrl-R searches incrementally. The default Node REPL cap of 30 lines is lifted — apyx keeps the full history.

A short writing transcript

apyx> await apyx.apxUSD.allowance(account.address, apyx.addresses.apyUSD)
0n

apyx> const tx = await apyx.apxUSD.approve({
        spender: apyx.addresses.apyUSD,
        amount:  viem.parseUnits('1', 6),
      })
{ hash: '0x3c6a…a9e1', wait: [Function: wait] }

apyx> await tx.wait()
{
  status: 'success',
  blockNumber: 19884112n,
  gasUsed: 48201n,
  ...
}

apyx> const dep = await apyx.apyUSD.deposit({
        assets:   viem.parseUnits('1', 6),
        receiver: account.address,
      })
apyx> await dep.wait()
{ status: 'success', blockNumber: 19884115n, gasUsed: 167822n, ... }

Exit

.exit, Ctrl-D, or Ctrl-C twice. On exit:

  • The Node REPL flushes history.
  • For signer.type = "ledger" profiles, the HID transport is closed cleanly (the device returns to its idle screen).

Session-Start Flags

Every input to createApyxClient is reachable as a CLI flag. Pass them on apyx repl or on any contract subcommand — they’re shared. Useful for one-session deviations from your config: swap in a paid RPC, point at a fork, override an address.

Flag table

FlagMaps toNotes
--profile <name>which profile to loadDefault: defaultProfile from the merged config. Errors Unknown profile if not present.
--rpc-url <url>profile.rpcUrlAny URL viem’s http() accepts.
--chain ethereum|baseprofile.chainTwo values today — see Supported Chains.
--key-path <path>profile.signer.keyPathReplaces the file the local-key signer reads. Implies signer.type = "key".
--address-apxusd <0x…>addresses.apxUSDOverride at construction. Must pass viem.isAddress(strict).
--address-apyusd <0x…>addresses.apyUSDOverride at construction.
--address-rate-view <0x…>addresses.apyUSDRateViewOverride at construction. Pass to enable the rate-view module on a chain that doesn’t natively have one.

Each flag maps to exactly one field on createApyxClient’s config. The CLI feeds an overrides struct in, and the SDK does its usual validation + freezing.

Where overrides apply

Overrides are session-start only. For apyx repl, they apply to the initial banner — but a mid-session .env <profile> swap reverts to the new profile’s own values.

sequenceDiagram
  participant User
  participant CLI as apyx repl
  participant SDK as createApyxClient
  User->>CLI: apyx repl --rpc-url $PRIVATE_RPC
  CLI->>CLI: load merged config
  CLI->>CLI: apply overrides from flags
  CLI->>SDK: { ... rpcUrl: $PRIVATE_RPC ... }
  SDK-->>CLI: ApyxClient
  CLI-->>User: banner: rpc: $PRIVATE_RPC (overridden)
  User->>CLI: .env base
  CLI->>CLI: drop overrides, reload base profile from config
  CLI->>SDK: createApyxClient with base profile defaults
  CLI-->>User: banner: rpc: https://mainnet.base.org (no override)

For non-interactive subcommands (every page under Contract Commands), overrides apply for that single invocation only.

Common patterns

Drop in a paid RPC for one session

apyx repl --rpc-url 'https://eth-mainnet.g.alchemy.com/v2/SECRET'

The banner reflects the override:

rpc:     https://eth-mainnet.g.alchemy.com/v2/SECRET (overridden)

The config file is untouched.

Switch chain for one command

apyx apyUSD exchange-rate --chain base --rpc-url https://mainnet.base.org

Useful in one-liners where you don’t want to touch the active profile.

Test against an anvil fork

apyx repl \
  --rpc-url http://127.0.0.1:8545 \
  --chain ethereum \
  --address-apyusd 0xRedeployed...  # if the fork redeployed apyUSD

The same flags drive the e2e suite — see E2E testing.

Use a different key file ad-hoc

apyx repl --key-path ~/.apyx/keys/treasury

Combined with a clean keyless config, this is a workable pattern for multi-account workflows: keep one read-only profile, point --key-path at whichever account you want to act as.

Bigint flag values (for contract subcommands)

Bigint args (--amount, --shares, --assets, etc.) are not session-start flags but they share the same parser. Accepted forms:

FormExampleResult
Plain digits12341234n
Underscore separators1_000_0001000000n
Scientific notation1e181000000000000000000n
Hex literal0xff255n

Floats are rejected. Use the preview methods (apyUSD preview-deposit, apyUSD convert-to-shares, etc.) when you need decimals-to-raw conversion.

Source-of-truth

The flag list lives in the CLI dispatch under src/cli/. Adding a new field to createApyxClient should land alongside the matching flag — flag parity is a hard rule, not a nice-to-have.

Ledger Setup

Sign transactions with a Ledger hardware wallet. The CLI integrates via @ledgerhq/hw-app-eth over USB-HID — every write prompts on the device before broadcast.

{
  "profiles": {
    "cold": {
      "chain": "ethereum",
      "rpcUrl": "https://eth.llamarpc.com",
      "signer": { "type": "ledger", "derivationPath": "m/44'/60'/0'/0/0" }
    }
  }
}

Install the optional deps

Ledger support is opt-in — the native HID stack adds a few hundred KB of bindings most consumers don’t want. The SDK lists them under optionalDependencies so a stock pnpm add @koed_jang/apyx-sdk does not pull them in:

pnpm add @ledgerhq/hw-transport-node-hid @ledgerhq/hw-app-eth

Both packages must be present at runtime, dynamically loaded by the CLI when it sees a signer.type = "ledger" profile. If they’re missing the CLI fails with a clear hint:

apyx: Ledger support requires optional deps. Install them with:
  pnpm add @ledgerhq/hw-transport-node-hid @ledgerhq/hw-app-eth

Add a Ledger profile

Edit the config file directly (the wizard supports it via signer type: ledger):

{
  "defaultProfile": "default",
  "profiles": {
    "default": {
      "chain": "ethereum",
      "rpcUrl": "https://eth.llamarpc.com",
      "signer": { "type": "key", "keyPath": "~/.apyx/keys/default" }
    },
    "cold": {
      "chain": "ethereum",
      "rpcUrl": "https://eth.llamarpc.com",
      "signer": {
        "type": "ledger",
        "derivationPath": "m/44'/60'/0'/0/0"
      }
    }
  }
}

The derivationPath is whatever path Ledger Live shows for the account you want — m/44'/60'/0'/0/N for the Nth account on a Legacy wallet, m/44'/60'/N'/0/0 for the Nth on a default 24-word setup.

Use it

  1. Plug the device in.

  2. Unlock it.

  3. Open the Ethereum app on the device.

  4. Run the CLI:

    $ apyx repl --profile cold
    @apyx-labs/sdk
    profile: cold (from /path/to/cwd/apyx.config.json)
    chain:   ethereum (1)
    rpc:     https://eth.llamarpc.com
    account: 0x1234…abcd
    

The address is fetched from the device — no key material ever leaves the device.

What you’ll see during a write

Every approve, deposit, redeem, etc. prompts on the device’s screen. You confirm:

  • the destination contract,
  • the function and decoded arguments,
  • the chainId,
  • the gas budget.

Only after you press Approve on the hardware does the CLI hand the tx to the RPC. There is no way to “auto-sign” — that’s the point.

apyx> await apyx.apyUSD.deposit({ assets: 1_000_000n, receiver: account.address })
[ledger]: review tx on device…
[ledger]: signed
{ hash: '0xbc9e…1d44', wait: [Function: wait] }

Reject on the device and the CLI surfaces a User refused on Ledger error.

Linux: udev rules

Linux needs a one-time udev rule so non-root processes can talk to the Ledger HID interface. The Ledger team publishes the canonical script:

wget -q -O - https://raw.githubusercontent.com/LedgerHQ/udev-rules/master/add_udev_rules.sh \
  | sudo bash

Re-plug the device after the rules land. Without this you’ll see Cannot open device when the CLI tries to enumerate USB.

macOS and Windows do not need extra rules — the bundled drivers work out of the box.

Cleanup

The HID transport is closed when:

  • The REPL exits via .exit, Ctrl-D, or Ctrl-C.
  • A non-interactive subcommand finishes (success or error).

If the device starts behaving oddly (DisconnectedDeviceDuringOperation, HID handle stuck), unplug + re-plug to reset the kernel-side state.

Testing without a device

The Ledger code path has an e2e spec that exercises the production dispatch using a mock factory backed by a real private key — no physical hardware required. See test/e2e/cli/ledger.e2e.spec.ts. That same factory hook lets you swap a mock in your own test harness; pass ledgerFactory to resolveSigner.

Contract Commands

Every SDK contract method is also a standalone non-interactive subcommand on the apyx CLI. Each has its own page below with a synopsis, argument table, example transcript, SDK equivalent, exit codes, and related recipes.

Quick reference

apxUSD (ERC-20 + permit)

CommandKindSDK
apyx apxUSD balance-ofreadapyx.apxUSD.balanceOf(owner)
apyx apxUSD allowancereadapyx.apxUSD.allowance(owner, spender)
apyx apxUSD total-supplyreadapyx.apxUSD.totalSupply()
apyx apxUSD approvewriteapyx.apxUSD.approve({ spender, amount })
apyx apxUSD transferwriteapyx.apxUSD.transfer({ to, amount })
apyx apxUSD permitwriteapyx.apxUSD.permit({ owner, spender, value, deadline, v, r, s })

apyUSD (ERC-4626 vault)

CommandKindSDK
apyx apyUSD exchange-ratereadapyx.apyUSD.exchangeRate()
apyx apyUSD total-assetsreadapyx.apyUSD.totalAssets()
apyx apyUSD convert-to-assetsreadapyx.apyUSD.convertToAssets(shares)
apyx apyUSD convert-to-sharesreadapyx.apyUSD.convertToShares(assets)
apyx apyUSD preview-depositreadapyx.apyUSD.previewDeposit(assets)
apyx apyUSD preview-redeemreadapyx.apyUSD.previewRedeem(shares)
apyx apyUSD balance-ofreadapyx.apyUSD.balanceOf(owner)
apyx apyUSD depositwriteapyx.apyUSD.deposit({ assets, receiver })
apyx apyUSD redeemwriteapyx.apyUSD.redeem({ shares, receiver, owner })

apyUSDRateView (Ethereum-only)

CommandKindSDK
apyx apyUSDRateView apyreadapyx.apyUSDRateView?.apy()
apyx apyUSDRateView annualized-yieldreadapyx.apyUSDRateView?.annualizedYield()
apyx apyUSDRateView vaultreadapyx.apyUSDRateView?.vault()

Common conventions

  • Flags are kebab-case and map to camelCase SDK arg names. --receiverreceiver. --key-pathkeyPath.
  • All session-start flags (--profile, --rpc-url, --chain, --key-path, --address-apxusd, --address-apyusd, --address-rate-view) work on every subcommand.
  • Writes default to wait-for-receipt. Pass --no-wait to exit after broadcast (returns the hash).
  • Bigint flags accept plain digits, underscores (1_234), scientific (1e18), and hex (0xff). Floats are rejected.
  • Exit codes: 0 success, 1 runtime error (read failure / revert / receipt error), 2 usage error.

The full source-of-truth for the registry and parsing rules is src/cli/commands/contract-registry.ts.

apyx apxUSD balance-of

Read the apxUSD ERC-20 balance of an address (raw, 6-decimal).

Synopsis

apyx apxUSD balance-of --owner <address>

Arguments

FlagTypeRequiredDescription
--owneraddressyesHolder address whose apxUSD balance to read.

Example

$ apyx apxUSD balance-of --owner 0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf
1234560000n

SDK equivalent

apyx.apxUSD.balanceOf(owner)

Reference: apxUSD module.

Flags

Inherits all session-start flags: --profile, --rpc-url, --chain, --key-path, --address-apxusd, --address-apyusd, --address-rate-view.

Exit codes

CodeMeaning
0read returned a value
1read error, transaction reverted, or receipt wait failed
2usage error (unknown flag, missing required arg, malformed value)

apyx apxUSD allowance

Read the ERC-20 allowance owner has granted spender on apxUSD.

Synopsis

apyx apxUSD allowance --owner <address> --spender <address>

Arguments

FlagTypeRequiredDescription
--owneraddressyesToken holder.
--spenderaddressyesSpender to query the allowance for.

Example

$ apyx apxUSD allowance \
    --owner 0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf \
    --spender 0x38EEb52F0771140d10c4E9A9a72349A329Fe8a6A
1000000n

SDK equivalent

apyx.apxUSD.allowance(owner, spender)

Reference: apxUSD module.

Flags

Inherits all session-start flags: --profile, --rpc-url, --chain, --key-path, --address-apxusd, --address-apyusd, --address-rate-view.

Exit codes

CodeMeaning
0read returned a value
1read error, transaction reverted, or receipt wait failed
2usage error (unknown flag, missing required arg, malformed value)

apyx apxUSD total-supply

Read the apxUSD total supply (raw, 6-decimal).

Synopsis

apyx apxUSD total-supply

Arguments

(no arguments)

Example

$ apyx apxUSD total-supply
48912430000000n

SDK equivalent

apyx.apxUSD.totalSupply()

Reference: apxUSD module.

Flags

Inherits all session-start flags: --profile, --rpc-url, --chain, --key-path, --address-apxusd, --address-apyusd, --address-rate-view.

Exit codes

CodeMeaning
0read returned a value
1read error, transaction reverted, or receipt wait failed
2usage error (unknown flag, missing required arg, malformed value)

apyx apxUSD approve

Set the ERC-20 allowance for a spender. Required before apyUSD deposit can pull funds.

Synopsis

apyx apxUSD approve --spender <address> --amount <bigint>

Arguments

FlagTypeRequiredDescription
--spenderaddressyesAddress authorised to spend up to --amount.
--amountbigintyesAmount in apxUSD raw units (6 decimals). 1e6 = 1 apxUSD.

Example

$ apyx apxUSD approve \
    --spender 0x38EEb52F0771140d10c4E9A9a72349A329Fe8a6A \
    --amount 1e6
hash: 0x3c6a…a9e1
waiting for receipt…
status: success
block:  19884112
gas:    48201

SDK equivalent

apyx.apxUSD.approve({ spender, amount })

Reference: apxUSD module.

Flags

Inherits all session-start flags: --profile, --rpc-url, --chain, --key-path, --address-apxusd, --address-apyusd, --address-rate-view.

Write-specific flags

FlagDefaultEffect
--wait / --no-wait--waitWait for the transaction receipt before exiting. --no-wait exits after broadcast (returns the hash).

Exit codes

CodeMeaning
0transaction broadcast (with --no-wait) or receipt landed with status: success
1read error, transaction reverted, or receipt wait failed
2usage error (unknown flag, missing required arg, malformed value)

apyx apxUSD transfer

Transfer apxUSD to another address.

Synopsis

apyx apxUSD transfer --to <address> --amount <bigint>

Arguments

FlagTypeRequiredDescription
--toaddressyesRecipient.
--amountbigintyesAmount in apxUSD raw units (6 decimals).

Example

$ apyx apxUSD transfer --to 0xabc… --amount 1e6
hash: 0xbc9e…1d44
waiting for receipt…
status: success
block:  19884115
gas:    52004

SDK equivalent

apyx.apxUSD.transfer({ to, amount })

Reference: apxUSD module.

Flags

Inherits all session-start flags: --profile, --rpc-url, --chain, --key-path, --address-apxusd, --address-apyusd, --address-rate-view.

Write-specific flags

FlagDefaultEffect
--wait / --no-wait--waitWait for the transaction receipt before exiting. --no-wait exits after broadcast (returns the hash).

Exit codes

CodeMeaning
0transaction broadcast (with --no-wait) or receipt landed with status: success
1read error, transaction reverted, or receipt wait failed
2usage error (unknown flag, missing required arg, malformed value)

apyx apxUSD permit

Submit an EIP-2612 typed-data signature to set an allowance gaslessly for the holder. The signature is produced off-chain by the holder; this command pays the gas to land it.

Synopsis

apyx apxUSD permit --owner <address> --spender <address> --value <bigint> --deadline <bigint> --v <uint8> --r <hex> --s <hex>

Arguments

FlagTypeRequiredDescription
--owneraddressyesHolder of the tokens (the signer of the typed data).
--spenderaddressyesAddress being approved.
--valuebigintyesApproved amount, in apxUSD raw units.
--deadlinebigintyesUnix timestamp after which the signature is invalid.
--vuint8yesSignature recovery id (0–255).
--rhexyesSignature r component (32-byte hex).
--shexyesSignature s component (32-byte hex).

Example

$ apyx apxUSD permit --owner 0x… --spender 0x… --value 1e6 \
    --deadline 1735689600 --v 27 --r 0x… --s 0x…
hash: 0x…
waiting for receipt…
status: success

SDK equivalent

apyx.apxUSD.permit({ owner, spender, value, deadline, v, r, s })

Reference: apxUSD module.

Flags

Inherits all session-start flags: --profile, --rpc-url, --chain, --key-path, --address-apxusd, --address-apyusd, --address-rate-view.

Write-specific flags

FlagDefaultEffect
--wait / --no-wait--waitWait for the transaction receipt before exiting. --no-wait exits after broadcast (returns the hash).

Exit codes

CodeMeaning
0transaction broadcast (with --no-wait) or receipt landed with status: success
1read error, transaction reverted, or receipt wait failed
2usage error (unknown flag, missing required arg, malformed value)

apyx apyUSD exchange-rate

Read the apyUSD vault’s exchange rate. Returns convertToAssets(1e18) — assets a single share is currently worth (18-decimal scaled).

Synopsis

apyx apyUSD exchange-rate

Arguments

(no arguments)

Example

$ apyx apyUSD exchange-rate
1041273481200000000n

SDK equivalent

apyx.apyUSD.exchangeRate()

Reference: apyUSD module.

Flags

Inherits all session-start flags: --profile, --rpc-url, --chain, --key-path, --address-apxusd, --address-apyusd, --address-rate-view.

Exit codes

CodeMeaning
0read returned a value
1read error, transaction reverted, or receipt wait failed
2usage error (unknown flag, missing required arg, malformed value)

apyx apyUSD total-assets

Read the apyUSD vault’s total assets under management (in apxUSD, 6-decimal).

Synopsis

apyx apyUSD total-assets

Arguments

(no arguments)

Example

$ apyx apyUSD total-assets
3208120000000n

SDK equivalent

apyx.apyUSD.totalAssets()

Reference: apyUSD module.

Flags

Inherits all session-start flags: --profile, --rpc-url, --chain, --key-path, --address-apxusd, --address-apyusd, --address-rate-view.

Exit codes

CodeMeaning
0read returned a value
1read error, transaction reverted, or receipt wait failed
2usage error (unknown flag, missing required arg, malformed value)

apyx apyUSD convert-to-assets

Quote how many apxUSD a given amount of apyUSD shares is worth at the current ratio.

Synopsis

apyx apyUSD convert-to-assets --shares <bigint>

Arguments

FlagTypeRequiredDescription
--sharesbigintyesQuantity of apyUSD shares to convert (18-decimal).

Example

$ apyx apyUSD convert-to-assets --shares 1e18
1041273481n

SDK equivalent

apyx.apyUSD.convertToAssets(shares)

Reference: apyUSD module.

Flags

Inherits all session-start flags: --profile, --rpc-url, --chain, --key-path, --address-apxusd, --address-apyusd, --address-rate-view.

Exit codes

CodeMeaning
0read returned a value
1read error, transaction reverted, or receipt wait failed
2usage error (unknown flag, missing required arg, malformed value)

apyx apyUSD convert-to-shares

Quote how many apyUSD shares a given amount of apxUSD would mint at the current ratio.

Synopsis

apyx apyUSD convert-to-shares --assets <bigint>

Arguments

FlagTypeRequiredDescription
--assetsbigintyesQuantity of apxUSD to convert (6-decimal).

Example

$ apyx apyUSD convert-to-shares --assets 1e6
960304728000000000n

SDK equivalent

apyx.apyUSD.convertToShares(assets)

Reference: apyUSD module.

Flags

Inherits all session-start flags: --profile, --rpc-url, --chain, --key-path, --address-apxusd, --address-apyusd, --address-rate-view.

Exit codes

CodeMeaning
0read returned a value
1read error, transaction reverted, or receipt wait failed
2usage error (unknown flag, missing required arg, malformed value)

apyx apyUSD preview-deposit

Pre-flight quote: shares minted for assets apxUSD at the current block, including any rounding.

Synopsis

apyx apyUSD preview-deposit --assets <bigint>

Arguments

FlagTypeRequiredDescription
--assetsbigintyesApxusd to deposit (6-decimal).

Example

$ apyx apyUSD preview-deposit --assets 1e6
960304728000000000n

SDK equivalent

apyx.apyUSD.previewDeposit(assets)

Reference: apyUSD module.

Flags

Inherits all session-start flags: --profile, --rpc-url, --chain, --key-path, --address-apxusd, --address-apyusd, --address-rate-view.

Exit codes

CodeMeaning
0read returned a value
1read error, transaction reverted, or receipt wait failed
2usage error (unknown flag, missing required arg, malformed value)

apyx apyUSD preview-redeem

Pre-flight quote: apxUSD assets returned for shares apyUSD at the current block.

Synopsis

apyx apyUSD preview-redeem --shares <bigint>

Arguments

FlagTypeRequiredDescription
--sharesbigintyesApyusd shares to redeem (18-decimal).

Example

$ apyx apyUSD preview-redeem --shares 1e18
1041273481n

SDK equivalent

apyx.apyUSD.previewRedeem(shares)

Reference: apyUSD module.

Flags

Inherits all session-start flags: --profile, --rpc-url, --chain, --key-path, --address-apxusd, --address-apyusd, --address-rate-view.

Exit codes

CodeMeaning
0read returned a value
1read error, transaction reverted, or receipt wait failed
2usage error (unknown flag, missing required arg, malformed value)

apyx apyUSD balance-of

Read the apyUSD share balance of an address (raw, 18-decimal).

Synopsis

apyx apyUSD balance-of --owner <address>

Arguments

FlagTypeRequiredDescription
--owneraddressyesHolder whose apyUSD share balance to read.

Example

$ apyx apyUSD balance-of --owner 0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf
5000000000000000000n

SDK equivalent

apyx.apyUSD.balanceOf(owner)

Reference: apyUSD module.

Flags

Inherits all session-start flags: --profile, --rpc-url, --chain, --key-path, --address-apxusd, --address-apyusd, --address-rate-view.

Exit codes

CodeMeaning
0read returned a value
1read error, transaction reverted, or receipt wait failed
2usage error (unknown flag, missing required arg, malformed value)

apyx apyUSD deposit

Deposit apxUSD into the apyUSD vault and mint shares to --receiver. Requires a prior apxUSD approve --spender <apyUSD> for at least --assets.

Synopsis

apyx apyUSD deposit --assets <bigint> --receiver <address>

Arguments

FlagTypeRequiredDescription
--assetsbigintyesApxUSD to deposit (6-decimal raw).
--receiveraddressyesAddress to receive the minted shares.

Example

$ apyx apyUSD deposit \
    --assets 1e6 \
    --receiver 0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf
hash: 0xbc9e…1d44
waiting for receipt…
status: success
block:  19884115
gas:    167822

SDK equivalent

apyx.apyUSD.deposit({ assets, receiver })

Reference: apyUSD module.

Flags

Inherits all session-start flags: --profile, --rpc-url, --chain, --key-path, --address-apxusd, --address-apyusd, --address-rate-view.

Write-specific flags

FlagDefaultEffect
--wait / --no-wait--waitWait for the transaction receipt before exiting. --no-wait exits after broadcast (returns the hash).

Exit codes

CodeMeaning
0transaction broadcast (with --no-wait) or receipt landed with status: success
1read error, transaction reverted, or receipt wait failed
2usage error (unknown flag, missing required arg, malformed value)

apyx apyUSD redeem

Burn apyUSD shares from --owner and deliver the corresponding apxUSD to --receiver.

Synopsis

apyx apyUSD redeem --shares <bigint> --receiver <address> --owner <address>

Arguments

FlagTypeRequiredDescription
--sharesbigintyesApyusd shares to burn (18-decimal raw).
--receiveraddressyesAddress to receive the unwrapped apxUSD.
--owneraddressyesAddress whose shares are burned. Must equal the signer or a pre-approved address.

Example

$ apyx apyUSD redeem \
    --shares 1e18 \
    --receiver 0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf \
    --owner    0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf
hash: 0x…
waiting for receipt…
status: success
block:  19884120
gas:    158400

SDK equivalent

apyx.apyUSD.redeem({ shares, receiver, owner })

Reference: apyUSD module.

Flags

Inherits all session-start flags: --profile, --rpc-url, --chain, --key-path, --address-apxusd, --address-apyusd, --address-rate-view.

Write-specific flags

FlagDefaultEffect
--wait / --no-wait--waitWait for the transaction receipt before exiting. --no-wait exits after broadcast (returns the hash).

Exit codes

CodeMeaning
0transaction broadcast (with --no-wait) or receipt landed with status: success
1read error, transaction reverted, or receipt wait failed
2usage error (unknown flag, missing required arg, malformed value)

apyx apyUSDRateView apy

Read the live APY of the apyUSD vault as an 18-decimal fraction (1.05e18 = 5%). Ethereum-only — fails on Base because no apyUSDRateView is deployed there.

Synopsis

apyx apyUSDRateView apy

Arguments

(no arguments)

Example

$ apyx apyUSDRateView apy
42345000000000000n

SDK equivalent

apyx.apyUSDRateView.apy()

Reference: apyUSDRateView module.

Flags

Inherits all session-start flags: --profile, --rpc-url, --chain, --key-path, --address-apxusd, --address-apyusd, --address-rate-view.

Exit codes

CodeMeaning
0read returned a value
1read error, transaction reverted, or receipt wait failed
2usage error (unknown flag, missing required arg, malformed value)

apyx apyUSDRateView annualized-yield

Read the annualised compounded yield (18-decimal fraction). Ethereum-only.

Synopsis

apyx apyUSDRateView annualized-yield

Arguments

(no arguments)

Example

$ apyx apyUSDRateView annualized-yield
43350000000000000n

SDK equivalent

apyx.apyUSDRateView.annualizedYield()

Reference: apyUSDRateView module.

Flags

Inherits all session-start flags: --profile, --rpc-url, --chain, --key-path, --address-apxusd, --address-apyusd, --address-rate-view.

Exit codes

CodeMeaning
0read returned a value
1read error, transaction reverted, or receipt wait failed
2usage error (unknown flag, missing required arg, malformed value)

apyx apyUSDRateView vault

Read the apyUSD vault address that the rate-view contract is pointed at. Should equal apyx.addresses.apyUSD.

Synopsis

apyx apyUSDRateView vault

Arguments

(no arguments)

Example

$ apyx apyUSDRateView vault
0x38EEb52F0771140d10c4E9A9a72349A329Fe8a6A

SDK equivalent

apyx.apyUSDRateView.vault()

Reference: apyUSDRateView module.

Flags

Inherits all session-start flags: --profile, --rpc-url, --chain, --key-path, --address-apxusd, --address-apyusd, --address-rate-view.

Exit codes

CodeMeaning
0read returned a value
1read error, transaction reverted, or receipt wait failed
2usage error (unknown flag, missing required arg, malformed value)

Read mainnet rate

The smallest possible end-to-end. Read the live apyUSD exchange rate and APY from Ethereum mainnet, format them for a human, exit cleanly. No wallet, no signing, no writes.

apyx apyUSD exchange-rate
apyx apyUSDRateView apy

What you’ll see

$ apyx apyUSD exchange-rate
1041273481200000000n

$ apyx apyUSDRateView apy
42345000000000000n

Both numbers are 18-decimal scaled bigints. 1.041e18 exchange rate means one apyUSD share is currently worth 1.041_273_481_2 apxUSD. 4.2345e16 APY means roughly 4.23% annualized.

Run it as a script

The same numbers from the SDK directly:

import { createApyxClient } from '@koed_jang/apyx-sdk';
import { http, formatUnits } from 'viem';
import { mainnet } from 'viem/chains';

const apyx = createApyxClient({
  chain: mainnet,
  transport: http(process.env.ETH_RPC_URL),
});

const [exchangeRate, apy] = await Promise.all([
  apyx.apyUSD.exchangeRate(),
  apyx.apyUSDRateView!.apy(),         // Ethereum-only
]);

console.log({
  exchangeRate: formatUnits(exchangeRate, 18),
  apy: `${(Number(formatUnits(apy, 18)) * 100).toFixed(4)}%`,
});

Promise.all on publicClient reads is essentially free — viem batches the JSON-RPC calls when the transport supports it.

Caveats

  • apyUSDRateView is Ethereum-only. On Base the field is undefined — guard with apyx.apyUSDRateView?.apy() if your code runs on both chains. See Supported Chains for the full per-chain availability matrix.
  • Public RPCs ratelimit. https://eth.llamarpc.com is fine for a one-shot read. For polling at a real cadence (every block, every 10s, etc.) drop in a private Alchemy / Infura / etc. URL — see the Custom RPC recipe.
  • Numbers are raw, not formatted. The SDK never formats — you do. Reach for viem’s formatUnits(value, 18) for human-readable strings.

Verifying the recipe

The SDK reads exercised here are covered by:

The CLI surface is covered by test/e2e/cli/methods.e2e.spec.ts.

Where to next

Approve and deposit

Wrap apxUSD into apyUSD. Two transactions:

  1. apxUSD.approve(spender = apyUSD, amount) — authorize the vault to pull funds.
  2. apyUSD.deposit({ assets, receiver }) — pull assets apxUSD and mint shares to receiver.
apyx apxUSD approve --spender 0x38EEb52F0771140d10c4E9A9a72349A329Fe8a6A --amount 1e6
apyx apyUSD deposit --assets 1e6 --receiver $(apyx repl <<<'console.log(account.address)' | tail -1)

Why two transactions

ERC-4626 vaults pull underlying tokens from the depositor via transferFrom. ERC-20 requires the holder approves the vault first. This is the canonical “approve + interact” pattern; apxUSD.permit collapses it to one tx if you’re willing to sign typed data instead — see “Skip the approve via permit” below.

SDK script

import { createApyxClient } from '@koed_jang/apyx-sdk';
import { http, parseUnits } from 'viem';
import { mainnet } from 'viem/chains';
import { privateKeyToAccount } from 'viem/accounts';

const account = privateKeyToAccount(process.env.PK as `0x${string}`);
const apyx = createApyxClient({
  chain: mainnet,
  transport: http(process.env.ETH_RPC_URL),
  account,
});

const amount = parseUnits('1', 6);     // 1 apxUSD (6-decimal)

// Step 1: approve apyUSD to pull `amount` apxUSD
const approve = await apyx.apxUSD.approve({
  spender: apyx.addresses.apyUSD,
  amount,
});
await approve.wait();

// Step 2: deposit and mint shares
const deposit = await apyx.apyUSD.deposit({
  assets:   amount,
  receiver: account.address,
});
const receipt = await deposit.wait();

console.log({
  hash:   deposit.hash,
  block:  receipt.blockNumber,
  shares: await apyx.apyUSD.balanceOf(account.address),
});

CLI walkthrough

$ apyx apxUSD approve \
    --spender 0x38EEb52F0771140d10c4E9A9a72349A329Fe8a6A \
    --amount  1e6
hash: 0x3c6a…a9e1
waiting for receipt…
status: success
block:  19884112
gas:    48201

$ apyx apyUSD deposit \
    --assets   1e6 \
    --receiver 0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf
hash: 0xbc9e…1d44
waiting for receipt…
status: success
block:  19884115
gas:    167822

After both land, your share balance reflects the deposit:

$ apyx apyUSD balance-of --owner 0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf
960304728000000000n

Quote ahead of time with apyx apyUSD preview-deposit:

$ apyx apyUSD preview-deposit --assets 1e6
960304728000000000n

previewDeposit is the on-chain quote at the current block — match this against balance-of after the deposit lands; they should agree exactly.

Skip the approve via permit

If your wallet supports EIP-2612 typed-data signing (most do — MetaMask, Rabby, Coinbase Wallet, Ledger Live), you can sign an allowance off-chain, hand the signature to anyone, and have them submit permit + deposit in a single bundle. The holder pays no gas; the relayer pays gas only for the deposit-side benefit.

The full typed-data construction is outside the scope of this recipe; viem’s signTypedData documentation walks the EIP-712 domain and struct hashing. Once you have (v, r, s):

const tx = await apyx.apxUSD.permit({
  owner, spender, value, deadline,
  v, r, s,
});
await tx.wait();

// then deposit, no separate approve required
const dep = await apyx.apyUSD.deposit({ assets: value, receiver });
await dep.wait();

Verifying the recipe

The two SDK paths exercised here are covered by:

CLI parity with these paths is covered by test/e2e/cli/methods.e2e.spec.ts.

Common pitfalls

  • The contract function "transferFrom" reverted with insufficient allowance — your approve didn’t land yet, you under-approved, or you’re on the wrong chain. Confirm with apyx apxUSD allowance --owner $YOU --spender $(apyx config show --profile default | jq -r .signer.…).
  • Approving max isn’t free. approve(maxUint256) saves a future tx but raises the blast radius if the vault is ever compromised. Approve exactly what you’ll deposit, or grant a working budget on a treasury account.
  • Stale approval after redeem. apyUSD.redeem doesn’t burn your approval — it’s still there for next time. That’s by design.

Redeem

Burn apyUSD shares back into apxUSD. The exit path. One transaction — no approval needed because you’re burning your own shares.

apyx apyUSD redeem \
  --shares   1e18 \
  --receiver 0xYOU \
  --owner    0xYOU

SDK script

import { createApyxClient } from '@koed_jang/apyx-sdk';
import { http, parseUnits } from 'viem';
import { mainnet } from 'viem/chains';
import { privateKeyToAccount } from 'viem/accounts';

const account = privateKeyToAccount(process.env.PK as `0x${string}`);
const apyx = createApyxClient({
  chain: mainnet,
  transport: http(process.env.ETH_RPC_URL),
  account,
});

const shares = await apyx.apyUSD.balanceOf(account.address);

const tx = await apyx.apyUSD.redeem({
  shares,
  receiver: account.address,
  owner:    account.address,
});
const receipt = await tx.wait();

console.log({
  hash:    tx.hash,
  block:   receipt.blockNumber,
  apxUSD:  await apyx.apxUSD.balanceOf(account.address),
});

CLI walkthrough

$ apyx apyUSD balance-of --owner 0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf
960304728000000000n

$ apyx apyUSD redeem \
    --shares   960304728000000000 \
    --receiver 0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf \
    --owner    0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf
hash: 0x…
waiting for receipt…
status: success
block:  19884120
gas:    158400

Quote in advance with apyx apyUSD preview-redeem:

$ apyx apyUSD preview-redeem --shares 960304728000000000
1000123n

That’s the apxUSD you’ll receive. If the vault has earned yield since your deposit, this number will exceed the apxUSD you originally deposited — that’s the whole point of the vault.

Why three address args?

ERC-4626 redeem takes three:

  • shares — exact share quantity to burn.
  • receiver — who gets the apxUSD. Often the same as owner, but you can route to a treasury or a different wallet.
  • owner — whose shares are burned. Must equal the signer, unless owner has approved the signer via apyUSD.approve (the share token has its own ERC-20 surface). Most consumers pass their own address for both owner and receiver.

The SDK forwards the args verbatim — pass owner = receiver = wallet.address unless you have a specific reason to split them.

Partial redeem

shares doesn’t have to equal your full balance. Burn a slice:

const all = await apyx.apyUSD.balanceOf(account.address);
const half = all / 2n;
await apyx.apyUSD.redeem({ shares: half, receiver: account.address, owner: account.address });

For an “exact apxUSD out” exit, use apyUSD.withdraw instead — same shape but you specify the assets you want and the contract burns whatever shares are needed.

Verifying the recipe

The path is covered by test/e2e/sdk/apyUSD.writes.fork.spec.ts (full deposit → redeem round-trip with a check that the post-redeem apxUSD balance matches previewRedeem).

Common pitfalls

  • ERC4626: redeem more than max — you asked to burn more shares than apyUSD.balanceOf(owner) returns. Trim to the actual balance.
  • InsufficientAllowance on redeem — the apyUSD share token has its own allowance surface. If signer ≠ owner, you need apyUSD.approve(signer, shares) first (called against the share token, not apxUSD).
  • No yield yet — if you redeem in the same block you deposited, previewRedeem(shares) returns the same assets you put in, modulo rounding. That’s correct, not a bug.

Multi-chain

Read or transact on Ethereum and Base in the same process. The SDK is per-chain — construct one ApyxClient per chain, share the rest of your application state.

const eth  = createApyxClient({ chain: mainnet, transport: http(ethRpc) });
const base = createApyxClient({ chain: baseChain, transport: http(baseRpc) });

Two clients, one process

import { createApyxClient } from '@koed_jang/apyx-sdk';
import { http, formatUnits } from 'viem';
import { mainnet, base } from 'viem/chains';

const eth = createApyxClient({
  chain: mainnet,
  transport: http(process.env.ETH_RPC_URL),
});

const baseClient = createApyxClient({
  chain: base,
  transport: http(process.env.BASE_RPC_URL),
});

const [ethRate, baseRate] = await Promise.all([
  eth.apyUSD.exchangeRate(),
  baseClient.apyUSD.exchangeRate(),
]);

console.log({
  ethereum: formatUnits(ethRate, 18),
  base:     formatUnits(baseRate, 18),
});

The two clients share nothing at runtime — different chain, different transport, different addresses. Each one fails fast on its own with UnsupportedChainError if you mistakenly pass the wrong viem chain to the wrong constructor.

Address book is per-chain

eth.addresses.apxUSD          // 0x98A878b1Cd98131B271883B390f68D2c90674665
baseClient.addresses.apxUSD   // 0xD993935E13851dd7517af10687EC7e5022127228

The Ethereum and Base apxUSD contracts share a name but are different deployments. Reading the wrong client’s address into a tx on the other chain is a foot-gun the SDK avoids by isolating each client’s frozen addresses object.

apyUSDRateView is Ethereum-only

eth.apyUSDRateView          // present
baseClient.apyUSDRateView   // undefined

Cross-chain code should always guard with ?.:

const apys = await Promise.all([
  eth.apyUSDRateView?.apy(),
  baseClient.apyUSDRateView?.apy(),
]);
// → [bigint, undefined]

CLI

The CLI is also per-session per-chain. Either:

  1. Switch profiles mid-session:

    $ apyx repl
    apyx> .env base
    apyx> await apyx.apyUSD.exchangeRate()
    
  2. Or fire two non-interactive subcommands:

    apyx apyUSD exchange-rate --chain ethereum --rpc-url $ETH
    apyx apyUSD exchange-rate --chain base     --rpc-url $BASE
    

A single REPL session can only have one active client at a time — running both chains side-by-side requires the SDK script approach above.

Sharing an account across chains

The account object can be reused — viem accounts are chain-agnostic.

import { privateKeyToAccount } from 'viem/accounts';

const account = privateKeyToAccount(process.env.PK as `0x${string}`);

const eth = createApyxClient({ chain: mainnet, transport: http(ethRpc), account });
const baseClient = createApyxClient({ chain: base, transport: http(baseRpc), account });

await Promise.all([
  eth.apxUSD.balanceOf(account.address),
  baseClient.apxUSD.balanceOf(account.address),
]);

Same address, two different on-chain balances. Useful for treasury dashboards.

Verifying the recipe

The cross-chain pattern is exercised by getAddresses + factory tests under test/e2e/sdk/apxUSD.fork.spec.ts and test/e2e/sdk/apyUSD.fork.spec.ts (the fork specs run against an Ethereum mainnet fork; Base is verified via unit tests on the address registry plus playground smoke tests).

The browser playground also covers the user-facing path (chain switcher + per-chain RPC override) — see Playground Coverage.

Custom RPC

Public RPC endpoints (https://eth.llamarpc.com, https://mainnet.base.org) are fine for one-off reads. For anything sustained — UI dashboards that poll, scripts that fan out reads, write workloads — use a paid provider. The SDK doesn’t care which; it’s plain http(url) to viem.

const apyx = createApyxClient({
  chain: mainnet,
  transport: http('https://eth-mainnet.g.alchemy.com/v2/SECRET'),
});

Why bother

Public RPCPaid RPC
Free$/req or monthly
Heavy ratelimits — 100s of req/min1k+ req/sec
Often missing archive historyFull archive
Variable latency, may drop callsConsistent <100ms
No support / SLASupport + uptime guarantee

For a UI that polls apyx.apyUSD.exchangeRate() every block, you will hit the public-RPC ratelimit fast. The error bubbles up as a viem HttpRequestError with status: 429 — fix it by switching to a paid endpoint.

SDK script

import { createApyxClient } from '@koed_jang/apyx-sdk';
import { http } from 'viem';
import { mainnet } from 'viem/chains';

const apyx = createApyxClient({
  chain: mainnet,
  transport: http(process.env.ETH_RPC_URL, {
    timeout: 10_000,        // fail-fast above 10s
    retryCount: 3,          // viem's built-in retry/backoff
  }),
});

http() supports the full viem transport options object — timeout, retry count, batch settings — without the SDK getting in the way.

CLI

Per-session via flag

The cleanest path. The config file stays clean; the secret URL never gets persisted:

apyx repl --rpc-url 'https://eth-mainnet.g.alchemy.com/v2/SECRET'

The banner reflects the override:

rpc:     https://eth-mainnet.g.alchemy.com/v2/SECRET (overridden)

Persistent via config

Write the URL into your home config so every session uses it:

{
  "profiles": {
    "default": {
      "chain": "ethereum",
      "rpcUrl": "https://eth-mainnet.g.alchemy.com/v2/SECRET",
      "signer": { "type": "key", "keyPath": "~/.apyx/keys/default" }
    }
  }
}
chmod 600 ~/.apyx/config.json

Lock down the config file — paid RPC URLs typically embed an API key. Anyone with the file can run requests on your bill.

Environment-variable indirection

A common mid-ground: keep the URL out of files, plumb it via env:

export ETH_RPC_URL='https://eth-mainnet.g.alchemy.com/v2/SECRET'
apyx repl --rpc-url "$ETH_RPC_URL"

In CI, set ETH_RPC_URL as a secret. Locally, direnv + a gitignored .envrc works well:

# .envrc
export ETH_RPC_URL='https://eth-mainnet.g.alchemy.com/v2/SECRET'

Browser playground

The playground has a per-chain RPC bar — see Playground Coverage. The override is persisted in localStorage per chain so refreshing the page keeps your URL in place. That URL never reaches the SDK package itself; it lives in the playground’s own state.

Provider notes

The SDK has been smoke-tested with the major providers below:

ProviderURL shapeNotes
Alchemyhttps://eth-mainnet.g.alchemy.com/v2/<KEY>Default for the maintainers’ dev workflow.
Infurahttps://mainnet.infura.io/v3/<KEY>
QuickNodehttps://<endpoint>.quiknode.pro/<KEY>/Trailing slash is fine.
Ankrhttps://rpc.ankr.com/ethFree tier ratelimits aggressively; paid tier OK.
Self-hosted (anvil / reth / geth)http://127.0.0.1:8545The e2e suite uses anvil mainnet forks at this URL — see E2E testing.

The SDK does no provider-specific shimming; if viem’s http() accepts your URL, the SDK does too.

Verifying the recipe

The session-flag override path is covered by test/e2e/cli/session-flags.e2e.spec.ts, which asserts that --rpc-url overrides the profile and shows up in the banner with the (overridden) suffix.

Ledger signing

End-to-end: install the optional deps, add a profile, plug in the device, sign an approve and a deposit from a hardware wallet.

pnpm add @ledgerhq/hw-transport-node-hid @ledgerhq/hw-app-eth
apyx repl --profile cold

Why hardware

Local-key signing is fine for a hot wallet with bounded funds — a testing account, an automated worker with daily-limited approvals. For anything at protocol scale, hardware-wallet signing means:

  • The private key never leaves the device.
  • Every transaction is reviewed on the device’s screen — destination contract, function, decoded args, chainId, gas cap.
  • An attacker on your laptop can submit a malicious tx to your CLI but cannot sign it.

The CLI integrates with Ledger via @ledgerhq/hw-app-eth over USB-HID. On every write, the device prompts and you confirm physically.

Step 1 — install the optional deps

The SDK lists Ledger packages under optionalDependencies, so a stock pnpm add @koed_jang/apyx-sdk does not pull them in. Add them when you want hardware support:

pnpm add @ledgerhq/hw-transport-node-hid @ledgerhq/hw-app-eth

If you forget, the CLI fails fast with the install hint:

apyx: Ledger support requires optional deps. Install them with:
  pnpm add @ledgerhq/hw-transport-node-hid @ledgerhq/hw-app-eth

Step 2 — add a Ledger profile

Edit ./apyx.config.json (or ~/.apyx/config.json):

{
  "defaultProfile": "default",
  "profiles": {
    "default": {
      "chain": "ethereum",
      "rpcUrl": "https://eth.llamarpc.com",
      "signer": { "type": "key", "keyPath": "~/.apyx/keys/default" }
    },
    "cold": {
      "chain": "ethereum",
      "rpcUrl": "https://eth-mainnet.g.alchemy.com/v2/SECRET",
      "signer": {
        "type": "ledger",
        "derivationPath": "m/44'/60'/0'/0/0"
      }
    }
  }
}

The derivation path matches whatever Ledger Live shows for the account you want to use.

Step 3 — physical setup

  1. Plug the device into USB.
  2. Unlock with your PIN.
  3. Open the Ethereum app on the device.

If the Ethereum app isn’t installed, install it from Ledger Live’s manager. The CLI talks only to the Ethereum app; other apps don’t expose the right methods.

Step 4 — open the REPL

$ apyx repl --profile cold
@apyx-labs/sdk
profile: cold (from /path/to/cwd/apyx.config.json)
chain:   ethereum (1)
rpc:     https://eth-mainnet.g.alchemy.com/v2/SECRET
account: 0x1234…abcd

The account: line is the address read from the device — no key material crossed the wire.

Step 5 — sign an approve

apyx> await apyx.apxUSD.approve({
        spender: apyx.addresses.apyUSD,
        amount:  viem.parseUnits('1', 6),
      })
[ledger]: review tx on device…

On the device’s screen:

Review transaction
─────────────────
Approve
1.00 apxUSD
to apyUSD vault
0x38EE…E8a6A

Network: Ethereum
Max fees: 0.00210 ETH
─────────────────
Approve and send
[Cancel] [Approve]

Press the right button to approve. The CLI returns once the device has produced a signature:

[ledger]: signed
{ hash: '0x3c6a…a9e1', wait: [Function: wait] }

apyx> await tx.wait()
{ status: 'success', blockNumber: 19884112n, ... }

Step 6 — sign a deposit

Same flow:

apyx> const dep = await apyx.apyUSD.deposit({
        assets:   viem.parseUnits('1', 6),
        receiver: account.address,
      })
[ledger]: review tx on device…
[ledger]: signed
apyx> await dep.wait()
{ status: 'success', ... }

Cleanup

.exit closes the HID transport cleanly. The device returns to its idle screen.

Linux: udev rules (one-time)

Linux requires udev rules so non-root processes can talk to the Ledger HID interface:

wget -q -O - https://raw.githubusercontent.com/LedgerHQ/udev-rules/master/add_udev_rules.sh \
  | sudo bash

Re-plug after the script runs. macOS and Windows do not need this.

Hardware-less testing

The Ledger code path can be tested without a physical device — the SDK exposes a ledgerFactory hook on resolveSigner that lets a test inject a mock backed by a real private key. See the canonical e2e spec:

  • test/e2e/cli/ledger.e2e.spec.ts — drives the full production dispatch with a crypto-backed mock against an anvil mainnet fork; lands an approve on chain.

This is the same pattern the SDK uses internally — your own test harness can do the same.

Common pitfalls

  • Cannot open device (Linux only) — udev rules missing. Run the Ledger script above.
  • UNKNOWN_ERROR (0x6804) — the Ethereum app isn’t open. Switch to it on the device and retry.
  • DisconnectedDeviceDuringOperation — the device went to sleep (long PIN timeout) or the USB handle got recycled by another process (browser USB pop-up, Ledger Live). Unplug + re-plug, retry.
  • Address mismatch — your config’s derivation path doesn’t match the account you think you’re using. apyx repl always prints the address it read from the device; cross-check that against Ledger Live before signing anything substantial.

Playground Overview

A browser-based developer playground for @koed_jang/apyx-sdk. Connect an injected wallet, point at any RPC, and exercise every SDK surface against Ethereum or Base mainnet — without writing a script.

Source lives at example/ in the repo, deployed (in #91) to a public Vercel URL.

Why a playground

Three audiences:

  • SDK consumers evaluating the package before integrating — clicking through every reading and writing surface in five minutes is faster than wiring a test app.
  • Maintainers validating a release end-to-end — the same UI also acts as a smoke harness for the published bundle.
  • Browser-vs-Node parity — the playground is the only place the browser ESM bundle is exercised at scale; the CLI exercises the Node CJS bundle. Bugs in either show up here.

What the page looks like

flowchart TB
  subgraph Header
    HC[Chain selector] --- HW[Wallet connect]
    HW --- HR[RPC bar]
  end
  subgraph Body
    AC[Account panel<br/>balances, allowance, share holdings]
    RP[Rate panel<br/>4 stat cards: exchange rate / total assets / APY / annualized yield]
    AP[Action panel<br/>approve / deposit / redeem with previews]
    RC[Raw Read Console<br/>arbitrary readContract against the exported ABIs]
    TX[Tx log<br/>hash / status / block / gas]
  end
  Header --> Body

Header lives at the top, accessible from every panel.

What it exercises

SurfaceHow
createApyxClientReconstructed on every chain switch and on every RPC override.
http transportReads always go through http(rpcUrl).
custom(window.ethereum) transportWrites go through the user’s wallet.
apxUSD readsAccount panel — balance, decimals, allowance for the active vault.
apxUSD.approveAction panel before a deposit, when allowance is insufficient.
apyUSD readsRate panel + account panel.
apyUSD.deposit / apyUSD.redeemAction panel, with previewDeposit / previewRedeem quotes shown alongside.
apyUSDRateViewRate panel — APY card; shown only on chains where the rate-view contract is deployed.
Exported ABIs (ApxUSDAbi, ApyUSDAbi, ApyUSDRateViewAbi)Raw Read Console — call any function name from any of the three ABIs.
Per-chain RPC overrideRPC bar — value persisted in localStorage so refreshes preserve it.
UnsupportedChainErrorTriggered by switching the wallet to e.g. Sepolia; the page renders a friendly empty state.

Status

Prototype, deployed alongside the SDK package itself. Same dist-tag, same versioning. Stable URL once #91 lands.

Next

  • Running locallypnpm install && pnpm --filter @apyx-labs/sdk-playground dev.
  • Coverage matrix — the table that maps SDK surfaces to UI elements, with the Playwright spec that verifies each.

Running the playground

The playground is a pnpm workspace inside the SDK repo. Start the dev server, point your browser at it, and connect a wallet.

pnpm install
pnpm --filter @apyx-labs/sdk-playground dev

Opens on http://localhost:5173.

Prerequisites

ToolVersionNotes
Node≥ 20Same requirement as the SDK + CLI.
pnpm≥ 9The repo is a pnpm workspace. corepack enable if not installed.
Browser walletEIP-1193MetaMask or Rabby tested; anything injected as window.ethereum works.

Dev server

From the repo root:

pnpm install
pnpm --filter @apyx-labs/sdk-playground dev

Internally that’s a Vite 6 dev server with hot reload. Edits to example/src/** reflect in the open browser tab without a full refresh.

The dev server links the SDK via the workspace protocol (workspace:*) — changes to src/** in the SDK package itself rebuild on save.

Production build

pnpm --filter @apyx-labs/sdk-playground build
StepWhat it does
tsc --noEmitType-checks the playground (the build script runs typecheck-then-build).
vite buildBundles into example/dist/.
OutputA static directory you can serve from any host.

For a quick local preview of the production bundle:

pnpm --filter @apyx-labs/sdk-playground preview

Connecting a wallet

The header exposes a Connect Wallet button. The injected provider is discovered via window.ethereum — no WalletConnect, no custom modal. If your browser has multiple wallets installed (MetaMask + Rabby + Phantom), whichever resolves window.ethereum first is used.

Without a wallet, the button label flips to “No wallet found” and the page stays in read-only mode. Read panels still populate (RPC reads don’t need a signer).

Choosing a chain

The chain selector drops down two options: Ethereum and Base. Selecting one:

  1. Reconstructs apyx against the new viem chain.
  2. Re-fetches the per-chain default RPC.
  3. Re-runs every read (balances, exchange rate, etc.).
  4. Hides any UI that’s not applicable on the chain (the APY card disappears on Base because apyUSDRateView isn’t deployed there).

Custom RPC

The header’s RPC bar overrides the default per chain. The value is persisted in localStorage keyed by chain — flipping between Ethereum and Base preserves each chain’s URL.

A blank input means “use the default” (the SDK’s built-in public RPC for that chain).

Writes

Once connected:

  1. Click Approve (action panel). MetaMask / your wallet pops up. Confirm.
  2. Watch the tx log for the hash and the receipt status.
  3. Click Deposit with an amount. Same flow.

The action panel always shows the live preview alongside the input (previewDeposit(amount) for deposit, previewRedeem(shares) for redeem). If the preview disagrees with the receipt, the vault rate moved between blocks — that’s normal and not a UI bug.

Tx log

Every transaction shows up with hash, status, block, and gas. Hover the hash to copy; click it to open in Etherscan / Basescan.

Troubleshooting

  • “No wallet found” — install MetaMask / Rabby and reload.
  • Wrong chain banner — the wallet is on a chain other than the one selected in the header. Switch in the wallet UI; the page updates automatically.
  • Reads stale after a write — refresh refetch happens on a 0/2/5/10s schedule after every write. RPC lag on the public default endpoints can take a few seconds to catch up; using a paid RPC eliminates this.
  • CORS error — your custom RPC URL doesn’t allow browser origins. This usually means a private node missing CORS headers; switch to a provider that explicitly allows browser access.

Coverage matrix

What each piece of the playground UI verifies, and which Playwright test guards it.

The single Playwright spec is at example/e2e/smoke.spec.ts. It runs in CI on every PR that touches example/** or src/**.

SDK surface ↔ playground UI

SDK surfaceWhere it appearsPlaywright assertion
createApyxClient (read-only)Reconstructed every chain switch / RPC change“renders the header title” — implies factory ran cleanly.
createApyxClient (with account)Set after wallet connect“Connect Wallet button is present” / “No wallet found” — both labels accepted because Chromium has no default window.ethereum.
apxUSD.balanceOf, apyUSD.balanceOfAccount panel“Account panel shows empty-state when no wallet is connected” guard.
apyUSD.exchangeRate, apyUSD.totalAssetsRate panel — “exchange rate” + “total assets” cards“Rate panel renders all four stat cards” — checks both labels visible.
apyUSDRateView.apy, apyUSDRateView.annualizedYieldRate panel — APY / annualized cards (Ethereum only)Same spec accepts either apy or apy / annualized yield depending on chain.
Exported ApxUSDAbi / ApyUSDAbi / ApyUSDRateViewAbiRaw Read Console“Raw Read Console is reachable” — element exists in DOM.
Default public RPC fallbackRPC bar placeholder“RPC bar shows the default public RPC as placeholder” — checks eth.llamarpc.com + the word default are in the placeholder text.
Per-chain RPC override + persistenceRPC bar localStorage key“RPC bar remembers per-chain overrides across reloads” — fills, reloads, asserts value persists.
UnsupportedChainErrorChain selector“chain selector lists Ethereum and Base” — limited to the two supported chains.

What’s not covered by the smoke spec

Hot-path writes (approve, deposit, redeem) need a real wallet provider. The smoke spec runs in headless Chromium with no window.ethereum, so it stops at “Connect Wallet button visible”. Write coverage is provided by the CLI fork e2e suite — same SDK, same writes, same anvil mainnet fork. See E2E testing for the breakdown.

flowchart TB
  subgraph Browser
    PW[Playwright Chromium] --> SMOKE[smoke.spec.ts]
    SMOKE --> RP[Read paths<br/>+ chain selector + RPC bar]
  end
  subgraph Node
    VTE[vitest e2e config] --> CLI[cli/* specs<br/>SDK + CLI writes via anvil]
    VTE --> SDK[sdk/* fork specs<br/>SDK contract modules]
  end
  RP -.together they cover.-> ALL[every public surface]
  CLI -.together they cover.-> ALL
  SDK -.together they cover.-> ALL

Running the spec locally

The Playwright config builds the playground first, then serves it on http://127.0.0.1:5173:

pnpm --filter @apyx-labs/sdk-playground build
pnpm --filter @apyx-labs/sdk-playground exec playwright install chromium
pnpm --filter @apyx-labs/sdk-playground test:e2e

Or if you only want to debug interactively:

pnpm --filter @apyx-labs/sdk-playground exec playwright test --ui

Adding new coverage

A new SDK feature surfaces in the playground typically means:

  1. Add a panel / control in example/src/**.
  2. Wire it through to the live apyx client.
  3. Add a test('...') block to example/e2e/smoke.spec.ts that asserts the new element is present (read paths) or that the appropriate disconnected-state copy is present (write paths that need a wallet).

Coverage parity is a soft rule: every public SDK surface should be reachable somewhere in either the playground or the CLI fork suite. The Playground Overview “What it exercises” table should be the audit checklist when this is in doubt.

Repo layout

A map of dead-pool-aka-wilson/apyx-sdk. The repo is a small pnpm workspace: one publishable package (the SDK), one in-tree playground (the example app), one mdBook (this manual).

Top level

apyx-sdk/
├── abis/                # JSON ABIs vendored from apyx-labs/subquery
├── book/                # mdBook user manual (this site)
├── example/             # browser playground (Vite + React 19 + Tailwind)
├── scripts/             # tsx generators (gen:abis, etc.)
├── src/                 # SDK + CLI source
├── test/                # vitest unit + e2e specs
├── package.json         # publishable package — @koed_jang/apyx-sdk
├── pnpm-workspace.yaml  # workspace: root + example/
├── rollup.config.mjs    # ESM (browser) + CJS (Node) + CLI bundles
├── tsconfig.json
├── vitest.config.ts     # unit tests
└── vitest.e2e.config.ts # fork + packaging tests

src/ — what gets published

src/
├── addresses.ts         # APYX_ADDRESSES + getAddresses + UnsupportedChainError
├── client.ts            # createApyxClient — the single public factory
├── errors.ts            # WalletClientRequiredError + InvalidAddressError
├── index.ts             # re-export barrel
├── contracts/
│   ├── apxUSD.ts        # ERC-20 + permit + supply views
│   ├── apyUSD.ts        # ERC-4626 + share-token ERC-20 surface
│   └── apyUSDRateView.ts# apy / annualizedYield / vault / precision
├── generated/           # `as const` ABI tuples (see `pnpm gen:abis`)
└── cli/                 # apyx CLI (Node-only)
    ├── cli.ts           # entry — argv parsing, dispatch
    ├── config.ts        # config schema, resolution rules, validation
    ├── repl.ts          # Node REPL with injected globals
    ├── signer.ts        # key + ledger dispatch (resolveSigner)
    ├── ledger.ts        # @ledgerhq/hw-app-eth dynamic import
    └── commands/        # contract-registry.ts (the 18-command source of truth)

The factory createApyxClient in src/client.ts is the gateway. Every consumer either calls it directly (programmatic) or via the CLI (which calls it under the hood).

example/ — playground

example/
├── src/                 # React app
├── e2e/smoke.spec.ts    # Playwright smoke (CI-runnable)
├── package.json         # @apyx-labs/sdk-playground (private)
├── playwright.config.ts
├── tailwind.config.js
├── vite.config.ts
└── README.md            # → see book/src/playground/*

The playground depends on the SDK via the workspace protocol ("@koed_jang/apyx-sdk": "workspace:*"). Edits to src/** reflect without re-publishing.

test/ — vitest

test/
├── *.spec.ts            # unit tests (run via `pnpm test`)
└── e2e/
    ├── fixtures/        # anvil + funded-account helpers
    ├── sdk/             # SDK module tests against an anvil mainnet fork
    ├── cli/             # CLI tests (config, repl, methods, session-flags, ledger)
    └── packaging/       # tarball install + CJS/ESM consumer smoke

Two vitest configs — vitest.config.ts for the unit suite, vitest.e2e.config.ts for fork + packaging. The split keeps the unit run under a few seconds and the e2e run isolated to PRs that touch src/** or test/e2e/** (path-filtered in CI).

book/ — this manual

Already familiar.

book/
├── book.toml          # mdBook config (mermaid + toc preprocessors)
├── src/SUMMARY.md     # chapter map
└── src/**/*.md

CI builds via mdbook build book and lychee-checks all intra-book links — see .github/workflows/docs.yml.

CI workflows

.github/workflows/
├── ci.yml          # lint + typecheck + unit tests on every push
├── e2e.yml         # fork tests (anvil via foundry-toolchain action) on src/** + test/e2e/** changes
├── packaging.yml   # nightly tarball install smoke
├── release.yml     # npm publish on `v*` tags
└── docs.yml        # mdbook build on book/** + src/** changes

Naming conventions

  • Files: kebab-case for everything user-facing (apyx-sdk, apyx.config.json, apxusd-balance-of.md); camelCase for TypeScript identifiers (createApyxClient, apxUSD.balanceOf); PascalCase for types and classes (ApyxClient, UnsupportedChainError).
  • Contract names: apxUSD (lowercase ‘a’), apyUSD (lowercase ‘a’), apyUSDRateView. The same casing is used in code, the CLI command tree, and the docs.
  • Imports: ESM-style from './x.js' (with .js extension) throughout src/ because the CJS bundle is built via Rollup with the same source.

Build and test

Every script you’ll run when working on the SDK itself.

pnpm install               # bootstrap
pnpm test                  # unit suite
pnpm typecheck             # tsc --noEmit
pnpm build                 # rollup + tsc --emitDeclarationOnly

Bootstrap

pnpm install

The repo is a pnpm workspace with two packages:

  • @koed_jang/apyx-sdk (publishable, root)
  • @apyx-labs/sdk-playground (private, example/)

pnpm install from the repo root installs both. The playground links the SDK via workspace:*.

Top-level scripts

The package.json scripts field has these entries:

ScriptWhat it does
pnpm testVitest unit suite (vitest.config.ts). Runs on every PR. Fast — typically <5s.
pnpm test:watchSame, but in watch mode.
pnpm test:e2eVitest e2e suite (vitest.e2e.config.ts) — fork tests via anvil + packaging tests. Requires TEST_ETH_RPC_URL.
pnpm test:e2e:sdkJust the SDK fork tests.
pnpm test:e2e:cliJust the CLI fork tests.
pnpm test:e2e:packagingJust the packaging tarball test. Slow — installs into a temp dir, ~3 min.
pnpm typechecktsc --noEmit over the whole package.
pnpm buildProduction build — Rollup for ESM + CJS + CLI bundles, then tsc --emitDeclarationOnly for .d.ts.
pnpm gen:abisRe-generate src/generated/* from the JSON ABIs in abis/. Run this after updating any vendored ABI JSON.

What pnpm build produces

Rollup emits three bundles plus type declarations:

dist/
├── index.browser.mjs    # ESM, browser-targeted (treeshakeable)
├── index.node.cjs       # CJS, Node-targeted
├── cli.cjs              # the apyx binary (with #!/usr/bin/env node banner)
└── index.d.ts           # type declarations

package.json exports and main / browser fields route consumers to the right bundle automatically:

CallerResolves to
import from a Vite / webpack browser appdist/index.browser.mjs
import from Node ESMdist/index.node.cjs (yes — CJS works fine in Node ESM)
require from Node CJSdist/index.node.cjs
Type-only consumersdist/index.d.ts
apyx bindist/cli.cjs (chmod 0755 by Rollup)

The dual bundle path is checked end-to-end by test/e2e/packaging/tarball.e2e.spec.ts — packs a tarball, installs it into a fresh project, asserts both import and require resolve cleanly.

Running the playground locally

pnpm --filter @apyx-labs/sdk-playground dev

See Running the playground for the full flow.

Running the docs locally

The book builds in seconds once mdBook is installed:

brew install mdbook                    # or: cargo install --locked mdbook
cargo install --locked mdbook-mermaid mdbook-toc
cd book && mdbook serve --open

mdbook serve watches src/**/*.md and rebuilds + reloads on save. For just the build (no server):

mdbook build book

CI sanity checks

Whatever you run locally, CI runs the same:

  • ci.ymlpnpm install, pnpm typecheck, pnpm test, pnpm build.
  • e2e.yml — fork suite via Foundry’s foundry-rs/foundry-toolchain action. Path-filtered to PRs that touch src/** or test/e2e/**.
  • packaging.yml — nightly pnpm test:e2e:packaging against the current main.
  • docs.ymlmdbook build book + lychee link check.

A green CI run on a PR is the fastest way to know your local env isn’t hiding a failure.

Local fork-test setup

If you want to run the e2e suite end-to-end yourself:

brew install foundryup
foundryup                              # installs anvil
export TEST_ETH_RPC_URL='https://eth-mainnet.g.alchemy.com/v2/YOURKEY'
pnpm test:e2e

The fixture pins anvil to a known-good post-deployment block so reads are deterministic. See E2E testing for the full plan.

  • Repo layout — where each script’s source lives.
  • E2E testing — what pnpm test:e2e actually verifies.
  • Releasing — what release.yml does on tag push.

End-to-end test plan — @koed_jang/apyx-sdk

Goals

Catch every regression reachable from a real consumer before it ships to npm. Concretely:

  1. SDK library — every public method on ApyxClient.{apxUSD,apyUSD,apyUSDRateView} returns the right shape, writes land on-chain, errors throw the declared error types.
  2. CLI — every subcommand in the registry works (reads and writes), every flag is honoured, exit codes are correct, help output is sane.
  3. Playground — connect wallet → read panels populate → deposit/redeem writes settle → toasts + tx log reflect state transitions.
  4. Packaging — the published tarball resolves as ESM from a Vite app and as CJS from Node; bin.apyx runs after npm install -g.

Out of scope for v0 of the test plan: cross-version compatibility (we pin viem 2.x), mobile wallets, gas-bump / retry middleware (not in the SDK), performance benchmarks.

Environment matrix

LayerRuntimeRPCSigning
Unit-E2ENode 20, vitestnone (mocked)mocked
Fork-integrationNode 20, vitest, anvilanvil mainnet fork pinned to a known blockephemeral private key loaded via privateKeyToAccount
Fork-integration (Ledger path)Node 20, vitestanvil forkmocked LedgerAppHandle — CI has no hardware
PlaygroundPlaywright Chromiumanvil fork (injected into the app via --rpc-url equivalent — the RPC bar input)Playwright-mocked injected provider wrapping the anvil RPC
PackagingNode 20, ephemeral workspace

Anvil is the one non-npm prerequisite. CI installs Foundry via the foundry-rs/foundry-toolchain action.

Fixtures

Anvil fork

test/e2e/fixtures/anvil.ts:

  • startAnvil({ forkUrl, forkBlockNumber }) — spawns anvil --fork-url <url> --fork-block-number <block>, returns { rpcUrl: 'http://127.0.0.1:<port>', stop() }.
  • Picks a free port per test run to allow parallel suites.
  • forkUrl sourced from TEST_ETH_RPC_URL env var. CI sets this to a dedicated Alchemy key in the repo secrets (separate from any developer’s personal key). Locally, developer can set it too.
  • forkBlockNumber pinned to a known-good post-apyUSD-deployment block so fixtures are deterministic.

Funded test account

Since apxUSD is permissioned (minted via MinterV0 with authorized minter signatures), we skip the real minter and set storage directly:

  • giveApxUSD({ client, account, amount }) uses viem’s setStorageAt against anvil to write the balance into apxUSD’s _balances mapping slot for account, and bumps the _totalSupply slot to match. Slot is computed once from the known storage layout (UUPS proxy storage slot + OZ ERC20 layout — documented in the fixture file).
  • anvil_setBalance for gas ETH.

If the storage-slot approach proves fragile (ABI or upgrade drift), fall back to impersonating the minter + submitting a signed order — wrapper lives in the same fixture file.

CLI runner

test/e2e/fixtures/cli.ts:

  • runCli(args, { cwd, env, stdin }) — spawns node dist/cli.cjs <args>, returns { stdout, stderr, exitCode }.
  • withTempConfig({ profile }, cb) — writes ./apyx.config.json and a hex key file into a unique tempdir, runs cb with { cwd, keyPath }, tears down.

Test categories

1. Unit-E2E (already in test/)

Current suites already cover the “flags parse correctly, registry is coherent, config resolves, completer matches” layer (95 tests across 12 files). Nothing to add here as part of this plan — just keep passing.

2. SDK fork-integration (test/e2e/sdk/**)

Each spec file owns one contract surface. All use the shared anvil fixture.

sdk/apxUSD.fork.spec.ts

  • reads: balanceOf, allowance, totalSupply, supplyCap, paused, decimals, nonces — assert live values against a known block.
  • writes: approve({ spender, amount }) → balance of allowance(owner, spender) goes from old → new; transfer({ to, amount }) decrements sender / increments receiver; permit(...) with a freshly-signed typed data → allowance updated without an approve tx.
  • error: write without account throws WalletClientRequiredError.

sdk/apyUSD.fork.spec.ts

  • reads: exchangeRate, totalAssets, convertToShares, convertToAssets, previewDeposit/Mint/Withdraw/Redeem, maxDeposit/Mint/Redeem/Withdraw, balanceOf, asset, decimals — sanity against the live fork block.
  • write: deposit({ assets, receiver }) — pre-condition: approve. Post-condition: apyUSD.balanceOf(receiver) grew by roughly previewDeposit(assets), apxUSD.balanceOf(depositor) shrank by assets, totalAssets grew.
  • write: redeem({ shares, receiver, owner }) — reverses the above (modulo unlock flow if the deployment uses it — if so, skip-tag and track in a follow-up; redeem may route through UnlockToken).
  • error: revert paths for zero amount.

sdk/apyUSDRateView.fork.spec.ts

  • apy(), annualizedYield(), vault() return non-negative bigints.
  • Mainnet profile: view is wired. Base profile: apyx.apyUSDRateView is undefined (asserted at client-construction time — no network needed).

sdk/client.fork.spec.ts

  • createApyxClient with http read transport + distinct walletTransport — read + write go through different URLs, verified by pointing each at a different anvil instance on two ports.
  • addresses override — passing a bogus apyUSD address surfaces a revert/zero-response path cleanly.
  • UnsupportedChainError when chain is not in the registry.

3. CLI fork-integration (test/e2e/cli/**)

Spawns dist/cli.cjs via execFile against the anvil fork.

cli/config.e2e.spec.ts

  • apyx config init with piped stdin writes a valid file.
  • apyx config show / path return expected output.
  • Unknown profile → exit 1.
  • Missing config → exit 1 with the canonical message.

cli/repl.e2e.spec.ts (scripted via stdin)

  • Feed await apyx.apyUSD.exchangeRate(); .exit\n — stdout includes a bigint literal; exit 0.
  • .env <other-profile> emits a second banner.

cli/methods.e2e.spec.ts — drives the whole registry

For every read in CONTRACT_COMMANDS:

  • run apyx <contract> <method> [flags] against the fork, assert stdout parses as a bigint or JSON, exit 0.

For every write:

  • run via a profile whose key is the funded test account.
  • assert stdout lines: hash: 0x…, status: success, block: <n>, gas: <n>. Exit 0.
  • with --no-wait: only hash: line, exit 0.
  • Trigger a revert (e.g. redeem 0 shares) → exit 1, stderr carries the viem shortMessage.

cli/session-flags.e2e.spec.ts

  • --rpc-url override observable via a read that’s only possible on the override URL (e.g. first anvil has state X, second has state Y — same contract address, different balance after a write).
  • --chain base flip on a profile configured as ethereum → reads apyUSD on base addresses (tested on a Base fork spun up alongside).
  • --key-path override swaps the signer; writes come from the new address.
  • --address-apyusd 0x… override reads from a different apyUSD (another test contract deployed on the fork via setStorageAt trick, or simply a bogus address that surfaces an error cleanly).

cli/ledger.e2e.spec.ts (unit-ish, no anvil)

  • Inject a mock LedgerAppHandle via an exported factory hook. Run apyx apyUSD deposit against a local anvil fork — signer is the mock Ledger, tx hash returns, receipt arrives. Exercises the full signTransaction round-trip without hardware.
  • If the mock returns an error on signTransaction → exit 1 with a useful message.

4. Packaging (test/e2e/packaging/**)

packaging/tarball.spec.ts

  • pnpm pack → install the resulting tarball into two ephemeral workspaces:
    • a Node CJS consumer: require('@koed_jang/apyx-sdk') returns createApyxClient, APYX_ADDRESSES, ABIs; a smoke http() + mainnet read works.
    • a Vite + React consumer: import { createApyxClient } from '@koed_jang/apyx-sdk' compiles, smoke test via vite-node.
  • npm install -g the tarball → apyx --version prints the package version.
  • Optional Ledger deps are listed in the extracted package.json but not required by default install.

5. Playground E2E (example/e2e/**, Playwright)

Anvil fork running; playground served via vite preview on a fixed port. Playwright drives Chromium with an injected EIP-1193 provider wrapping the anvil RPC.

  • Connection: load page, inject provider, click Connect, assert the truncated address + green dot appear.
  • Unsupported chain banner: swap the provider’s chainId to 42161, assert amber banner + “Switch” button appears.
  • AccountPanel: balances populate, raw bigint + formatted both visible, copy button works (clipboard mocked).
  • RatePanel: apy, annualizedYield, exchangeRate, totalAssets all non-empty; Refresh triggers re-fetch.
  • Deposit: enter 1 apxUSD, click Approve → toast pending → success in under TEST_TX_TIMEOUT_MS (parametric). Then click Deposit → second toast chain.
  • Redeem: half the resulting shares, click Redeem, toast cycles, AccountPanel balances update automatically within the 10s ceiling without manual Refresh.
  • Error toast: redeem 0 shares, assert the red toast with a decoded revert message, and that it auto-dismisses after ~10s.
  • MAX button: deposit form sets input to the full balance (exact bigint round-trip).
  • Raw Read Console: select apyUSD → totalAssets, click Call, assert the JSON matches the RatePanel.
  • RPC bar: paste a custom URL, reload page, value persists from localStorage; Reset clears.

CI integration

Two workflow jobs, gated by path filters:

  • ci.yml (existing) — installs deps, runs pnpm typecheck, pnpm test (unit-E2E), pnpm build, playground build. Stays fast (~1 min).
  • e2e.yml (new) — runs on main + on PRs touching src/**, example/src/**, abis/**. Installs Foundry via foundry-rs/foundry-toolchain@v1, boots anvil forks as needed, runs pnpm test:e2e. Time budget ~8 min.
  • packaging.yml (new) — cron nightly + on release tag. Runs pnpm pack + the packaging specs. Catches files / exports misconfigurations that the unit suites miss.

Secrets required:

  • TEST_ETH_RPC_URL — RPC for the mainnet fork base. Alchemy’s free tier is sufficient for the block ranges we need.

Tooling choices

  • Test runner: vitest for everything except the browser playground (Playwright).
  • Anvil runner: child_process.spawn + port probe helper. No docker.
  • Fork block: a constant FORK_BLOCK_NUMBER in a shared fixture. Bump only deliberately, with an accompanying PR note on which fixture-value goldens shift.
  • Playwright: stable config in example/playwright.config.ts. Chromium only — we don’t ship Safari/Firefox-specific code.

Issues to open (one PR each)

  1. chore/e2e-anvil-fixture — shared anvil spawn/teardown + giveApxUSD storage-slot helper. Lands the fixture, no test specs yet.
  2. test/e2e-sdk-reads — per-contract read specs on top of the fixture.
  3. test/e2e-sdk-writes — deposit / approve / redeem write round-trips.
  4. test/e2e-cli-config-and-repl — config commands + REPL-via-stdin specs.
  5. test/e2e-cli-methods — driver over CONTRACT_COMMANDS for every read + write.
  6. test/e2e-cli-session-flags — flag-override coverage.
  7. test/e2e-cli-ledger-mock — Ledger signer path with a mocked hw-app-eth.
  8. test/e2e-packaging-tarballpnpm pack + install-and-import smoke in CJS + ESM.
  9. test/e2e-playground-playwright — browser-side coverage.
  10. ci/e2e-workflow — new GitHub Actions workflow(s) wiring the above behind path filters.

Each issue lists its slice of the plan so PRs stay single-purpose.

Rollout phases

Phase 1 (quick win): issues 1–3. SDK read + write confidence against a fork — the biggest source of silent regressions today.

Phase 2 (CLI depth): issues 4–7. Catches flag-parsing and dispatch bugs that unit-E2E can’t.

Phase 3 (publishable): issues 8 + 10. Packaging + CI. This is the gate for v0.2.0 going out to npm.

Phase 4 (UI): issue 9. Playground is already hand-tested; Playwright catches regressions once we start iterating on it more.

Open questions

  • MinterV0 path for funding apxUSD — the storage-slot trick assumes OZ’s ERC20Upgradeable layout under a UUPS proxy. Confirm the slot with a one-off test before committing the fixture. If a proxy layout quirk breaks it, fall back to impersonating an approved minter and submitting a real signed order (fixture complexity roughly doubles).
  • Base fork — the plan assumes we can run two anvils in parallel for cross-chain flag tests. Fine if Foundry’s anvil is installed once; still O(2×RAM). If CI slots are a constraint, merge the --chain base override tests into a single fork cycle by reusing one anvil process with different fork-url restarts.
  • Playwright RPC stubbing — Playwright’s Route.fulfill lets us intercept JSON-RPC responses, but the playground uses the wallet’s RPC once connected. If that stays true in the final build, the injected provider must proxy to anvil directly — not through Playwright’s route interceptor.

When Phase 1 starts, lock in answers via fixture-file comments.

Releasing

How a new version of @koed_jang/apyx-sdk ends up on npm.

# 1. bump
pnpm version 0.1.0-prototype.1 --no-git-tag-version
git commit -am "release: 0.1.0-prototype.1"

# 2. tag and push
git tag v0.1.0-prototype.1
git push --follow-tags

GitHub Actions (.github/workflows/release.yml) does the rest.

Versioning policy

StageVersion patternnpm dist-tag
Prototype (current)0.1.0-prototype.Nprototype
Stable (future)0.X.Y (no suffix)latest
Future pre-releases-rc.N / -beta.N / -alpha.Nrc / beta / alpha

The release workflow derives the dist-tag from the version itself:

if   [[ "$VERSION" == *-prototype* ]]; then TAG=prototype
elif [[ "$VERSION" == *-rc*        ]]; then TAG=rc
elif [[ "$VERSION" == *-beta*      ]]; then TAG=beta
elif [[ "$VERSION" == *-alpha*     ]]; then TAG=alpha
else                                        TAG=latest
fi

So the rule is simple: pick the right -suffix.N and CI picks the right tag. Never publish a prototype to latest and consumers can’t accidentally pin to it via pnpm add @koed_jang/apyx-sdk (no suffix).

Tag format

Tags are v<version>v0.1.0-prototype.0, v0.1.0, etc. The release workflow triggers on v*, so any pushed tag matching that pattern publishes.

What CI does on tag push

release.yml (linked):

  1. actions/checkout@v4 at the tag’s commit.
  2. pnpm/action-setup@v4 (pnpm 9), actions/setup-node@v4 (Node 20) with registry-url: https://registry.npmjs.org.
  3. pnpm install --frozen-lockfile.
  4. pnpm build — Rollup bundles + tsc declarations.
  5. Derive the dist-tag (script above).
  6. pnpm publish --no-git-checks --tag <derived> with NODE_AUTH_TOKEN = secrets.NPM_TOKEN.

The --no-git-checks flag is required because the GitHub Actions checkout is a detached HEAD with no remote tracking branch — pnpm otherwise refuses to publish.

Why no --provenance

npm’s sigstore-backed --provenance flag requires a public source repo so the resulting attestation can be verified back to the build. The current dev repo is private, so --provenance would fail at publish time. Once the source forks back to a public apyx-labs/sdk, re-add --provenance to release.yml.

Pre-flight checklist

Before pushing the tag:

  • pnpm test — unit suite green.
  • pnpm typecheck — no errors.
  • pnpm build — bundles emit cleanly.
  • pnpm test:e2e or wait for CI to run the equivalent on the release commit before tagging.
  • pnpm pack --dry-run — inspect what’s about to ship.
  • Bump in package.json, pnpm install to refresh the lockfile, commit.
  • Tag and push: git tag v<version> && git push --follow-tags.

CI runs the same checks on the tag commit, so a green local run is a strong predictor that the publish will succeed.

After publishing

Verify on npm:

pnpm view @koed_jang/apyx-sdk versions
pnpm view @koed_jang/apyx-sdk dist-tags

Smoke-test from a fresh directory:

mkdir /tmp/apyx-smoke && cd /tmp/apyx-smoke
pnpm init -y
pnpm add @koed_jang/apyx-sdk@prototype viem
pnpm exec apyx --version

A successful apyx --version with the version you just published confirms the install + bin paths end-to-end.

First-publish gotcha — spurious latest tag

The very first pnpm publish --tag prototype for a brand-new package also seeds latest to the same version (npm’s default behavior — the package needs a latest to be installable). On a prototype-first workflow, this is wrong — consumers should be forced to opt in via the prototype tag.

Once-per-package fix, run by a maintainer locally:

npm dist-tag rm @koed_jang/apyx-sdk latest

After this, pnpm add @koed_jang/apyx-sdk (no tag) fails with No matching version found for @koed_jang/apyx-sdk@* — exactly what we want until a stable release.

Cadence

  • Prototype releases — as needed; expected weekly during active dev.
  • Stable 0.1.0 — gated on the protocol team confirming the public API surface. No date.
  • Repo layout — where the publish artifacts come from.
  • Build and test — what pnpm build produces.
  • Install — how consumers install whatever you just published.