@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 Reference —
apyxcommand, 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
- With pnpm
- With npm
- With yarn
- Without installing —
dlx/npx - Optional: Ledger hardware-wallet support
- Why
prototypeand notlatest? - Verify the install
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:
pnpm add @koed_jang/apyx-sdk@prototypealways pulls the newest prototype build, never accidentally a future stable.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 — the address book and chainId table.
- Approve and deposit recipe — the full apxUSD → apyUSD round-trip end-to-end.
- SDK Reference: createApyxClient — every option, every error.
- CLI Reference: REPL — interactive shell with the
exact same
apyxclient constructed for you.
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
- Address checksumming and immutability
apyUSDRateViewis optional per chain- Overriding addresses (forks, testnets, custom deployments)
- Adding a new chain
Address book
| Chain | chainId | apxUSD | apyUSD | apyUSDRateView |
|---|---|---|---|---|
| Ethereum mainnet | 1 | 0x98A878b1Cd98131B271883B390f68D2c90674665 | 0x38EEb52F0771140d10c4E9A9a72349A329Fe8a6A | 0xab3Aa53D942cbFb58773856BdE4F3c3EFbaf0fDc |
| Base mainnet | 8453 | 0xD993935E13851dd7517af10687EC7e5022127228 | 0x2c271ddF484aC0386d216eB7eB9Ff02D4Dc0F6AA | — |
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:
- Validation. A malformed address (non-hex, wrong length, bad
case-checksum) raises
InvalidAddressErrorat construction. Failure is loud and immediate, not at first call. - Normalization. The returned
apyx.addressesobject 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 - Module map
- Public exports at a glance
- Read-only vs write-enabled
- Where to next
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.
| Export | Kind | Page |
|---|---|---|
createApyxClient | factory function | client |
ApyxClient, ApyxClientConfig, ApyxClientCore | types | client |
getAddresses, APYX_ADDRESSES, ApyxAddresses | function + constant + type | addresses |
UnsupportedChainError, WalletClientRequiredError, InvalidAddressError | error classes | errors |
ApxUSDAbi, ApyUSDAbi, ApyUSDRateViewAbi | typed as const ABI arrays | abis |
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? | walletClient | reads | writes |
|---|---|---|---|
| no | undefined | ✅ | throw WalletClientRequiredError |
| yes | WalletClient | ✅ | ✅ |
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
walletTransportescape hatch. - apxUSD, apyUSD, apyUSDRateView — per-method reference.
- Errors — the three thrown error classes and how to recover.
- Addresses —
APYX_ADDRESSESregistry, 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>;
};
| Field | Required | Effect |
|---|---|---|
chain | yes | viem Chain object. Drives chainId-based address lookup, gas estimation, and EIP-1559 vs legacy tx defaults. |
transport | yes | viem Transport. Used for reads by the underlying publicClient, and for writes too unless walletTransport is set. Typically http(rpcUrl). |
walletTransport | no | Separate 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. |
account | no | Either 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. |
addresses | no | Partial<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
- Resolves addresses. Looks up
chain.idinAPYX_ADDRESSES; throwsUnsupportedChainErrorfor any unknown chainId. - Merges overrides.
{...builtin, ...config.addresses}. - Validates and freezes addresses. Each non-null entry runs through
viem.isAddress(value, { strict: true }). Failure raisesInvalidAddressError. The result isObject.freezed soapyx.addressescannot be mutated post-construction. - Builds clients. A
publicClientis always built. AwalletClientis built only whenaccountis non-null. - Wires modules.
apxUSD(core),apyUSD(core), and (if the chain has aapyUSDRateViewaddress)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
| Error | When | How to recover |
|---|---|---|
UnsupportedChainError | chain.id is not in the built-in registry | Either pass a supported chain (Ethereum or Base) or override addresses with the redeploy targets. |
InvalidAddressError | A 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.
| Method | Returns | Notes |
|---|---|---|
balanceOf(owner: Address) | bigint | Raw token balance, 18-decimal scaled. |
allowance(owner, spender) | bigint | ERC-20 allowance. |
totalSupply() | bigint | Total minted apxUSD. |
supplyCap() | bigint | Max permitted supply (protocol guardrail). |
supplyCapRemaining() | bigint | Headroom = supplyCap - totalSupply. |
paused() | boolean | true if the contract is paused (writes will revert). |
decimals() | number | Always 6 today, but read it rather than hardcode. |
nonces(owner: Address) | bigint | EIP-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
| Method | Returns | Notes |
|---|---|---|
asset() | Address | Underlying asset (always apxUSD). |
totalAssets() | bigint | Vault TVL in apxUSD (6-decimal). |
convertToShares(assets: bigint) | bigint | apxUSD → apyUSD at the current ratio. |
convertToAssets(shares: bigint) | bigint | apyUSD → apxUSD. |
exchangeRate() | bigint | Convenience: 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.
| Method | Returns |
|---|---|
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
| Method | Returns |
|---|---|
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
| Method | Returns | Notes |
|---|---|---|
balanceOf(owner) | bigint | apyUSD share balance, 18-decimal. |
decimals() | number | Always 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.
depositandmintpullapxUSDfrom the sender viatransferFrom. You mustapprovethe 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
| Method | Returns | Notes |
|---|---|---|
apy() | bigint | Current APY as an 18-decimal fraction. 1.05e18 = 5% APY. Format with viem.formatUnits(apy, 18) for a decimal string. |
annualizedYield() | bigint | Annualised yield, same 18-decimal scaling. Equivalent to compounding apy() over a year. UIs often display this alongside (or instead of) raw APY. |
precision() | bigint | The contract’s internal precision base (e.g. 1e18). Useful when you want to do percentage math without trusting the format. |
vault() | Address | Address 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
addressesat construction:createApyxClient({ chain: someOtherChain, transport: http(), addresses: { apxUSD: '0x…', apyUSD: '0x…', apyUSDRateView: '0x…', // optional }, });Note that
addressesis aPartial<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 omitapyUSDRateViewif it isn’t deployed there).
WalletClientRequiredError
Thrown by: any write method on apxUSD or apyUSD —
approve, 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:
0xprefix- 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';
ApyxAddressesAPYX_ADDRESSESgetAddresses(chainId)- Override semantics
- Validation and freezing
- Consumers in code
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 chainId | returns the entry from APYX_ADDRESSES. |
| Unknown chainId | throws 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 rule | Effect |
|---|---|
Must begin with 0x | else InvalidAddressError |
Must be exactly 42 chars (0x + 40 hex) | else InvalidAddressError |
| Either all-lowercase or correctly EIP-55 mixed-case | else 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
permitdefinition.
For everything else, the typed module wrappers are a shorter path.
Available ABIs
| Export | Contract | What it covers |
|---|---|---|
ApxUSDAbi | apxUSD | ERC-20 + EIP-2612 permit + supply cap views + pausability. |
ApyUSDAbi | apyUSD | Full ERC-4626 (deposit/mint/withdraw/redeem + their previews + max-views) plus the share-token ERC-20 surface. |
ApyUSDRateViewAbi | apyUSDRateView | apy(), 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.
Related
- SDK Overview — how the ABIs feed into the typed contract modules.
- apxUSD, apyUSD, apyUSDRateView — wrapped, ready-to-use modules that internally use these same ABIs.
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:
- Configuration — your first config file.
- REPL — the interactive shell.
- Contract Commands — the per-command index.
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/.binif you installed globally (pnpm/yarn/npm all do this automatically; ifapyxisn’t found, runpnpm setupor 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 };
| Field | Required | Notes |
|---|---|---|
defaultProfile | no | Name of the profile to use when no --profile flag is passed. Must match a key in profiles. |
profiles | yes | At least one entry — apyx config init creates default. |
profile.chain | yes | "ethereum" or "base". Drives chainId-based address lookup in APYX_ADDRESSES. |
profile.rpcUrl | yes | Non-empty string. Public endpoints work for reads; for writes/heavy reads use a paid RPC. |
profile.signer | no | When 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]
| Step | Detail |
|---|---|
| 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. |
| Merge | Project 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). |
defaultProfile | Project defaultProfile wins if set; else home’s; else the first profile in iteration order. |
| No config anywhere | Most 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
| Rule | Effect |
|---|---|
| First non-empty line is read | Trailing comments / mnemonics on later lines are ignored, but cleaner files are easier to audit — keep them one-line. |
0x prefix is optional | Both 0xabcd… and abcd… are accepted. |
| Must decode to exactly 32 bytes | Otherwise: key file does not contain hex / expected 32 bytes (64 hex chars). |
| Permissions are checked | If 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:
| Subcommand | Page |
|---|---|
apyx config init | config init |
apyx config show | config show |
apyx config path | config path |
Next
- REPL — what happens when you run
apyx replwith 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).
| Prompt | Default | Effect |
|---|---|---|
chain | ethereum | Sets profiles.default.chain. |
rpcUrl | https://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 type | key | Picks the signer shape. none writes a profile without a signer (read-only). |
keyPath | ~/.apyx/keys/default | Only asked when signer type = key. Path is stored verbatim — the wizard does not create the file. |
derivationPath | m/44'/60'/0'/0/0 | Only 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
| Code | Meaning |
|---|---|
| 0 | Wrote a new config file |
| 1 | User declined to overwrite an existing file, or wizard aborted via ^C |
Common follow-ups
After config init:
- Drop your hex private key into the
keyPathyou chose:echo '0xYOUR_KEY' > ~/.apyx/keys/default chmod 600 ~/.apyx/keys/default - Verify the resolved config:
apyx config show. - Open the REPL:
apyx repl.
Related
- Configuration — the full config schema.
- config show — inspect what
initwrote. - config path — print the resolved config file path.
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>]
| Flag | Default | Effect |
|---|---|---|
--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
| Code | Meaning |
|---|---|
| 0 | Printed the (filtered) config |
| 1 | No config found / unknown profile |
| 2 | Usage error (unknown flag) |
Related
- Configuration — schema and resolution rules.
- config path — print the resolved file paths.
- Session-Start Flags — overrides applied after
config showreads the merged baseline.
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.jsonis 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
| Code | Meaning |
|---|---|
| 0 | Printed at least one path |
| 1 | No config files found in either location |
| 2 | Usage error (unknown flag) |
Related
- Configuration — schema and resolution rules.
- config show — print the merged contents.
- config init — create a project config.
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
Banner
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
| Line | Meaning |
|---|---|
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:
| Global | Type | Notes |
|---|---|---|
apyx | ApyxClient | The full constructed client — apyx.publicClient, apyx.walletClient (if signer), apyx.addresses, apyx.apxUSD, apyx.apyUSD, apyx.apyUSDRateView?. |
account | Account | undefined | The active viem Account, or undefined for read-only profiles. |
chain | Chain | viem’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-urloverride 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).
Related
- Configuration — what feeds the banner.
- Session-Start Flags — overriding the active profile’s fields without rewriting the config.
- Contract Commands — every REPL operation reachable as a non-interactive subcommand.
- Ledger Setup — using a hardware-wallet profile.
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
- Where overrides apply
- Common patterns
- Bigint flag values (for contract subcommands)
- Source-of-truth
Flag table
| Flag | Maps to | Notes |
|---|---|---|
--profile <name> | which profile to load | Default: defaultProfile from the merged config. Errors Unknown profile if not present. |
--rpc-url <url> | profile.rpcUrl | Any URL viem’s http() accepts. |
--chain ethereum|base | profile.chain | Two values today — see Supported Chains. |
--key-path <path> | profile.signer.keyPath | Replaces the file the local-key signer reads. Implies signer.type = "key". |
--address-apxusd <0x…> | addresses.apxUSD | Override at construction. Must pass viem.isAddress(strict). |
--address-apyusd <0x…> | addresses.apyUSD | Override at construction. |
--address-rate-view <0x…> | addresses.apyUSDRateView | Override 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:
| Form | Example | Result |
|---|---|---|
| Plain digits | 1234 | 1234n |
| Underscore separators | 1_000_000 | 1000000n |
| Scientific notation | 1e18 | 1000000000000000000n |
| Hex literal | 0xff | 255n |
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
- Add a Ledger profile
- Use it
- What you’ll see during a write
- Linux: udev rules
- Cleanup
- Testing without a device
- Related
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
-
Plug the device in.
-
Unlock it.
-
Open the Ethereum app on the device.
-
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, orCtrl-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.
Related
- Configuration —
signer.type = "ledger"schema. - REPL — what the banner shows for a Ledger profile.
- Ledger signing recipe — full setup walkthrough end-to-end.
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)
| Command | Kind | SDK |
|---|---|---|
apyx apxUSD balance-of | read | apyx.apxUSD.balanceOf(owner) |
apyx apxUSD allowance | read | apyx.apxUSD.allowance(owner, spender) |
apyx apxUSD total-supply | read | apyx.apxUSD.totalSupply() |
apyx apxUSD approve | write | apyx.apxUSD.approve({ spender, amount }) |
apyx apxUSD transfer | write | apyx.apxUSD.transfer({ to, amount }) |
apyx apxUSD permit | write | apyx.apxUSD.permit({ owner, spender, value, deadline, v, r, s }) |
apyUSD (ERC-4626 vault)
| Command | Kind | SDK |
|---|---|---|
apyx apyUSD exchange-rate | read | apyx.apyUSD.exchangeRate() |
apyx apyUSD total-assets | read | apyx.apyUSD.totalAssets() |
apyx apyUSD convert-to-assets | read | apyx.apyUSD.convertToAssets(shares) |
apyx apyUSD convert-to-shares | read | apyx.apyUSD.convertToShares(assets) |
apyx apyUSD preview-deposit | read | apyx.apyUSD.previewDeposit(assets) |
apyx apyUSD preview-redeem | read | apyx.apyUSD.previewRedeem(shares) |
apyx apyUSD balance-of | read | apyx.apyUSD.balanceOf(owner) |
apyx apyUSD deposit | write | apyx.apyUSD.deposit({ assets, receiver }) |
apyx apyUSD redeem | write | apyx.apyUSD.redeem({ shares, receiver, owner }) |
apyUSDRateView (Ethereum-only)
| Command | Kind | SDK |
|---|---|---|
apyx apyUSDRateView apy | read | apyx.apyUSDRateView?.apy() |
apyx apyUSDRateView annualized-yield | read | apyx.apyUSDRateView?.annualizedYield() |
apyx apyUSDRateView vault | read | apyx.apyUSDRateView?.vault() |
Common conventions
- Flags are kebab-case and map to camelCase SDK arg names.
--receiver↔receiver.--key-path↔keyPath. - 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-waitto exit after broadcast (returns the hash). - Bigint flags accept plain digits, underscores (
1_234), scientific (1e18), and hex (0xff). Floats are rejected. - Exit codes:
0success,1runtime error (read failure / revert / receipt error),2usage 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
| Flag | Type | Required | Description |
|---|---|---|---|
--owner | address | yes | Holder 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
| Code | Meaning |
|---|---|
| 0 | read returned a value |
| 1 | read error, transaction reverted, or receipt wait failed |
| 2 | usage error (unknown flag, missing required arg, malformed value) |
Related
apyx apxUSD allowance
Read the ERC-20 allowance owner has granted spender on apxUSD.
Synopsis
apyx apxUSD allowance --owner <address> --spender <address>
Arguments
| Flag | Type | Required | Description |
|---|---|---|---|
--owner | address | yes | Token holder. |
--spender | address | yes | Spender 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
| Code | Meaning |
|---|---|
| 0 | read returned a value |
| 1 | read error, transaction reverted, or receipt wait failed |
| 2 | usage error (unknown flag, missing required arg, malformed value) |
Related
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
| Code | Meaning |
|---|---|
| 0 | read returned a value |
| 1 | read error, transaction reverted, or receipt wait failed |
| 2 | usage error (unknown flag, missing required arg, malformed value) |
Related
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
| Flag | Type | Required | Description |
|---|---|---|---|
--spender | address | yes | Address authorised to spend up to --amount. |
--amount | bigint | yes | Amount 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
| Flag | Default | Effect |
|---|---|---|
--wait / --no-wait | --wait | Wait for the transaction receipt before exiting. --no-wait exits after broadcast (returns the hash). |
Exit codes
| Code | Meaning |
|---|---|
| 0 | transaction broadcast (with --no-wait) or receipt landed with status: success |
| 1 | read error, transaction reverted, or receipt wait failed |
| 2 | usage error (unknown flag, missing required arg, malformed value) |
Related
apyx apxUSD transfer
Transfer apxUSD to another address.
Synopsis
apyx apxUSD transfer --to <address> --amount <bigint>
Arguments
| Flag | Type | Required | Description |
|---|---|---|---|
--to | address | yes | Recipient. |
--amount | bigint | yes | Amount 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
| Flag | Default | Effect |
|---|---|---|
--wait / --no-wait | --wait | Wait for the transaction receipt before exiting. --no-wait exits after broadcast (returns the hash). |
Exit codes
| Code | Meaning |
|---|---|
| 0 | transaction broadcast (with --no-wait) or receipt landed with status: success |
| 1 | read error, transaction reverted, or receipt wait failed |
| 2 | usage error (unknown flag, missing required arg, malformed value) |
Related
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
| Flag | Type | Required | Description |
|---|---|---|---|
--owner | address | yes | Holder of the tokens (the signer of the typed data). |
--spender | address | yes | Address being approved. |
--value | bigint | yes | Approved amount, in apxUSD raw units. |
--deadline | bigint | yes | Unix timestamp after which the signature is invalid. |
--v | uint8 | yes | Signature recovery id (0–255). |
--r | hex | yes | Signature r component (32-byte hex). |
--s | hex | yes | Signature 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
| Flag | Default | Effect |
|---|---|---|
--wait / --no-wait | --wait | Wait for the transaction receipt before exiting. --no-wait exits after broadcast (returns the hash). |
Exit codes
| Code | Meaning |
|---|---|
| 0 | transaction broadcast (with --no-wait) or receipt landed with status: success |
| 1 | read error, transaction reverted, or receipt wait failed |
| 2 | usage error (unknown flag, missing required arg, malformed value) |
Related
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
| Code | Meaning |
|---|---|
| 0 | read returned a value |
| 1 | read error, transaction reverted, or receipt wait failed |
| 2 | usage error (unknown flag, missing required arg, malformed value) |
Related
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
| Code | Meaning |
|---|---|
| 0 | read returned a value |
| 1 | read error, transaction reverted, or receipt wait failed |
| 2 | usage error (unknown flag, missing required arg, malformed value) |
Related
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
| Flag | Type | Required | Description |
|---|---|---|---|
--shares | bigint | yes | Quantity 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
| Code | Meaning |
|---|---|
| 0 | read returned a value |
| 1 | read error, transaction reverted, or receipt wait failed |
| 2 | usage error (unknown flag, missing required arg, malformed value) |
Related
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
| Flag | Type | Required | Description |
|---|---|---|---|
--assets | bigint | yes | Quantity 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
| Code | Meaning |
|---|---|
| 0 | read returned a value |
| 1 | read error, transaction reverted, or receipt wait failed |
| 2 | usage error (unknown flag, missing required arg, malformed value) |
Related
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
| Flag | Type | Required | Description |
|---|---|---|---|
--assets | bigint | yes | Apxusd 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
| Code | Meaning |
|---|---|
| 0 | read returned a value |
| 1 | read error, transaction reverted, or receipt wait failed |
| 2 | usage error (unknown flag, missing required arg, malformed value) |
Related
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
| Flag | Type | Required | Description |
|---|---|---|---|
--shares | bigint | yes | Apyusd 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
| Code | Meaning |
|---|---|
| 0 | read returned a value |
| 1 | read error, transaction reverted, or receipt wait failed |
| 2 | usage error (unknown flag, missing required arg, malformed value) |
Related
apyx apyUSD balance-of
Read the apyUSD share balance of an address (raw, 18-decimal).
Synopsis
apyx apyUSD balance-of --owner <address>
Arguments
| Flag | Type | Required | Description |
|---|---|---|---|
--owner | address | yes | Holder 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
| Code | Meaning |
|---|---|
| 0 | read returned a value |
| 1 | read error, transaction reverted, or receipt wait failed |
| 2 | usage error (unknown flag, missing required arg, malformed value) |
Related
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
| Flag | Type | Required | Description |
|---|---|---|---|
--assets | bigint | yes | ApxUSD to deposit (6-decimal raw). |
--receiver | address | yes | Address 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
| Flag | Default | Effect |
|---|---|---|
--wait / --no-wait | --wait | Wait for the transaction receipt before exiting. --no-wait exits after broadcast (returns the hash). |
Exit codes
| Code | Meaning |
|---|---|
| 0 | transaction broadcast (with --no-wait) or receipt landed with status: success |
| 1 | read error, transaction reverted, or receipt wait failed |
| 2 | usage error (unknown flag, missing required arg, malformed value) |
Related
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
| Flag | Type | Required | Description |
|---|---|---|---|
--shares | bigint | yes | Apyusd shares to burn (18-decimal raw). |
--receiver | address | yes | Address to receive the unwrapped apxUSD. |
--owner | address | yes | Address 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
| Flag | Default | Effect |
|---|---|---|
--wait / --no-wait | --wait | Wait for the transaction receipt before exiting. --no-wait exits after broadcast (returns the hash). |
Exit codes
| Code | Meaning |
|---|---|
| 0 | transaction broadcast (with --no-wait) or receipt landed with status: success |
| 1 | read error, transaction reverted, or receipt wait failed |
| 2 | usage error (unknown flag, missing required arg, malformed value) |
Related
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
| Code | Meaning |
|---|---|
| 0 | read returned a value |
| 1 | read error, transaction reverted, or receipt wait failed |
| 2 | usage error (unknown flag, missing required arg, malformed value) |
Related
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
| Code | Meaning |
|---|---|
| 0 | read returned a value |
| 1 | read error, transaction reverted, or receipt wait failed |
| 2 | usage error (unknown flag, missing required arg, malformed value) |
Related
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
| Code | Meaning |
|---|---|
| 0 | read returned a value |
| 1 | read error, transaction reverted, or receipt wait failed |
| 2 | usage error (unknown flag, missing required arg, malformed value) |
Related
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
apyUSDRateViewis Ethereum-only. On Base the field isundefined— guard withapyx.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.comis 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:
test/e2e/sdk/apyUSD.fork.spec.ts—exchangeRateand the underlyingconvertToAssets(1e18)against an anvil mainnet fork.test/e2e/sdk/apyUSDRateView.fork.spec.ts— every read on the rate-view module.
The CLI surface is covered by
test/e2e/cli/methods.e2e.spec.ts.
Where to next
- Approve and deposit — the full apxUSD → apyUSD round-trip.
- Multi-chain — read the same value on both Ethereum and Base in one process.
- Custom RPC — wire in a paid endpoint.
Approve and deposit
Wrap apxUSD into apyUSD. Two transactions:
apxUSD.approve(spender = apyUSD, amount)— authorize the vault to pull funds.apyUSD.deposit({ assets, receiver })— pullassetsapxUSD and mint shares toreceiver.
apyx apxUSD approve --spender 0x38EEb52F0771140d10c4E9A9a72349A329Fe8a6A --amount 1e6
apyx apyUSD deposit --assets 1e6 --receiver $(apyx repl <<<'console.log(account.address)' | tail -1)
- Why two transactions
- SDK script
- CLI walkthrough
- Skip the approve via permit
- Verifying the recipe
- Common pitfalls
- Related
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:
test/e2e/sdk/apxUSD.writes.fork.spec.ts—approve,transfer,permiton apxUSD against an anvil mainnet fork (realtransferFromchain on the next call).test/e2e/sdk/apyUSD.writes.fork.spec.ts—deposit,redeem,mint,withdrawon apyUSD with the apxUSD-funded test account.
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— yourapprovedidn’t land yet, you under-approved, or you’re on the wrong chain. Confirm withapyx 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.redeemdoesn’t burn your approval — it’s still there for next time. That’s by design.
Related
- apxUSD module, apyUSD module — full SDK reference.
- Redeem — the inverse: apyUSD → apxUSD.
apxUSD approve,apyUSD deposit— CLI reference.
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
- CLI walkthrough
- Why three address args?
- Partial redeem
- Verifying the recipe
- Common pitfalls
- Related
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 asowner, but you can route to a treasury or a different wallet.owner— whose shares are burned. Must equal the signer, unlessownerhas approved the signer viaapyUSD.approve(the share token has its own ERC-20 surface). Most consumers pass their own address for bothownerandreceiver.
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 thanapyUSD.balanceOf(owner)returns. Trim to the actual balance.InsufficientAllowanceonredeem— the apyUSD share token has its own allowance surface. Ifsigner ≠ owner, you needapyUSD.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 sameassetsyou put in, modulo rounding. That’s correct, not a bug.
Related
- Approve and deposit — the inverse path.
apyUSD redeem,apyUSD preview-redeem,apyUSD balance-of— CLI reference.- apyUSD module — full SDK reference.
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
- Address book is per-chain
apyUSDRateViewis Ethereum-only- CLI
- Sharing an account across chains
- Verifying the recipe
- Related
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:
-
Switch profiles mid-session:
$ apyx repl apyx> .env base apyx> await apyx.apyUSD.exchangeRate() -
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.
Related
- Supported Chains — address book and per-chain availability.
- Custom RPC — how to feed two paid endpoints.
- Addresses — registry + override semantics.
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 RPC | Paid RPC |
|---|---|
| Free | $/req or monthly |
| Heavy ratelimits — 100s of req/min | 1k+ req/sec |
| Often missing archive history | Full archive |
| Variable latency, may drop calls | Consistent <100ms |
| No support / SLA | Support + 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:
| Provider | URL shape | Notes |
|---|---|---|
| Alchemy | https://eth-mainnet.g.alchemy.com/v2/<KEY> | Default for the maintainers’ dev workflow. |
| Infura | https://mainnet.infura.io/v3/<KEY> | |
| QuickNode | https://<endpoint>.quiknode.pro/<KEY>/ | Trailing slash is fine. |
| Ankr | https://rpc.ankr.com/eth | Free tier ratelimits aggressively; paid tier OK. |
| Self-hosted (anvil / reth / geth) | http://127.0.0.1:8545 | The 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.
Related
- Session-Start Flags —
--rpc-urlin the full flag table. - Configuration — persisting an RPC URL via the config file.
- Multi-chain — wiring two paid endpoints (one per chain).
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
- Step 1 — install the optional deps
- Step 2 — add a Ledger profile
- Step 3 — physical setup
- Step 4 — open the REPL
- Step 5 — sign an approve
- Step 6 — sign a deposit
- Cleanup
- Linux: udev rules (one-time)
- Hardware-less testing
- Common pitfalls
- Related
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
- Plug the device into USB.
- Unlock with your PIN.
- 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 anapproveon 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 replalways prints the address it read from the device; cross-check that against Ledger Live before signing anything substantial.
Related
- Ledger Setup — full reference (installation, udev, HID lifecycle).
- Approve and deposit — the two-step flow you just signed.
- Configuration —
signer.type = "ledger"schema.
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
| Surface | How |
|---|---|
createApyxClient | Reconstructed on every chain switch and on every RPC override. |
http transport | Reads always go through http(rpcUrl). |
custom(window.ethereum) transport | Writes go through the user’s wallet. |
apxUSD reads | Account panel — balance, decimals, allowance for the active vault. |
apxUSD.approve | Action panel before a deposit, when allowance is insufficient. |
apyUSD reads | Rate panel + account panel. |
apyUSD.deposit / apyUSD.redeem | Action panel, with previewDeposit / previewRedeem quotes shown alongside. |
apyUSDRateView | Rate 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 override | RPC bar — value persisted in localStorage so refreshes preserve it. |
UnsupportedChainError | Triggered 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 locally —
pnpm 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
- Dev server
- Production build
- Connecting a wallet
- Choosing a chain
- Custom RPC
- Writes
- Tx log
- Troubleshooting
- Related
Prerequisites
| Tool | Version | Notes |
|---|---|---|
| Node | ≥ 20 | Same requirement as the SDK + CLI. |
| pnpm | ≥ 9 | The repo is a pnpm workspace. corepack enable if not installed. |
| Browser wallet | EIP-1193 | MetaMask 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
| Step | What it does |
|---|---|
tsc --noEmit | Type-checks the playground (the build script runs typecheck-then-build). |
vite build | Bundles into example/dist/. |
| Output | A 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:
- Reconstructs
apyxagainst the new viem chain. - Re-fetches the per-chain default RPC.
- Re-runs every read (balances, exchange rate, etc.).
- Hides any UI that’s not applicable on the chain (the APY card
disappears on Base because
apyUSDRateViewisn’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:
- Click Approve (action panel). MetaMask / your wallet pops up. Confirm.
- Watch the tx log for the hash and the receipt status.
- 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.
Related
- Playground Overview — what’s on the page.
- Coverage matrix — what each panel verifies and the Playwright spec that tests it.
- Custom RPC recipe — the same RPC story on the Node side.
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
- What’s not covered by the smoke spec
- Running the spec locally
- Adding new coverage
- Related
SDK surface ↔ playground UI
| SDK surface | Where it appears | Playwright 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.balanceOf | Account panel | “Account panel shows empty-state when no wallet is connected” guard. |
apyUSD.exchangeRate, apyUSD.totalAssets | Rate panel — “exchange rate” + “total assets” cards | “Rate panel renders all four stat cards” — checks both labels visible. |
apyUSDRateView.apy, apyUSDRateView.annualizedYield | Rate panel — APY / annualized cards (Ethereum only) | Same spec accepts either apy or apy / annualized yield depending on chain. |
Exported ApxUSDAbi / ApyUSDAbi / ApyUSDRateViewAbi | Raw Read Console | “Raw Read Console is reachable” — element exists in DOM. |
| Default public RPC fallback | RPC 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 + persistence | RPC bar localStorage key | “RPC bar remembers per-chain overrides across reloads” — fills, reloads, asserts value persists. |
UnsupportedChainError | Chain 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:
- Add a panel / control in
example/src/**. - Wire it through to the live
apyxclient. - Add a
test('...')block toexample/e2e/smoke.spec.tsthat 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.
Related
- Playground Overview — what the page does.
- Running locally — dev server, prod build, wallet connection.
- E2E testing — the broader test plan; the Playwright spec is one entry in the matrix.
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
src/— what gets publishedexample/— playgroundtest/— vitestbook/— this manual- CI workflows
- Naming conventions
- Related
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.jsextension) throughoutsrc/because the CJS bundle is built via Rollup with the same source.
Related
- Build and test — what each of these scripts actually runs.
- E2E testing — the test plan.
- Releasing — what happens when
release.ymlfires.
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
- Top-level scripts
- What
pnpm buildproduces - Running the playground locally
- Running the docs locally
- CI sanity checks
- Local fork-test setup
- Related
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:
| Script | What it does |
|---|---|
pnpm test | Vitest unit suite (vitest.config.ts). Runs on every PR. Fast — typically <5s. |
pnpm test:watch | Same, but in watch mode. |
pnpm test:e2e | Vitest e2e suite (vitest.e2e.config.ts) — fork tests via anvil + packaging tests. Requires TEST_ETH_RPC_URL. |
pnpm test:e2e:sdk | Just the SDK fork tests. |
pnpm test:e2e:cli | Just the CLI fork tests. |
pnpm test:e2e:packaging | Just the packaging tarball test. Slow — installs into a temp dir, ~3 min. |
pnpm typecheck | tsc --noEmit over the whole package. |
pnpm build | Production build — Rollup for ESM + CJS + CLI bundles, then tsc --emitDeclarationOnly for .d.ts. |
pnpm gen:abis | Re-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:
| Caller | Resolves to |
|---|---|
import from a Vite / webpack browser app | dist/index.browser.mjs |
import from Node ESM | dist/index.node.cjs (yes — CJS works fine in Node ESM) |
require from Node CJS | dist/index.node.cjs |
| Type-only consumers | dist/index.d.ts |
apyx bin | dist/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.yml—pnpm install,pnpm typecheck,pnpm test,pnpm build.e2e.yml— fork suite via Foundry’sfoundry-rs/foundry-toolchainaction. Path-filtered to PRs that touchsrc/**ortest/e2e/**.packaging.yml— nightlypnpm test:e2e:packagingagainst the currentmain.docs.yml—mdbook 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.
Related
- Repo layout — where each script’s source lives.
- E2E testing — what
pnpm test:e2eactually verifies. - Releasing — what
release.ymldoes 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:
- SDK library — every public method on
ApyxClient.{apxUSD,apyUSD,apyUSDRateView}returns the right shape, writes land on-chain, errors throw the declared error types. - CLI — every subcommand in the registry works (reads and writes), every flag is honoured, exit codes are correct, help output is sane.
- Playground — connect wallet → read panels populate → deposit/redeem writes settle → toasts + tx log reflect state transitions.
- Packaging — the published tarball resolves as ESM from a Vite app and as CJS from Node;
bin.apyxruns afternpm 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
| Layer | Runtime | RPC | Signing |
|---|---|---|---|
| Unit-E2E | Node 20, vitest | none (mocked) | mocked |
| Fork-integration | Node 20, vitest, anvil | anvil mainnet fork pinned to a known block | ephemeral private key loaded via privateKeyToAccount |
| Fork-integration (Ledger path) | Node 20, vitest | anvil fork | mocked LedgerAppHandle — CI has no hardware |
| Playground | Playwright Chromium | anvil fork (injected into the app via --rpc-url equivalent — the RPC bar input) | Playwright-mocked injected provider wrapping the anvil RPC |
| Packaging | Node 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 })— spawnsanvil --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.
forkUrlsourced fromTEST_ETH_RPC_URLenv 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.forkBlockNumberpinned 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’ssetStorageAtagainst anvil to write the balance into apxUSD’s_balancesmapping slot foraccount, and bumps the_totalSupplyslot to match. Slot is computed once from the known storage layout (UUPS proxy storage slot + OZ ERC20 layout — documented in the fixture file).anvil_setBalancefor 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 })— spawnsnode dist/cli.cjs <args>, returns{ stdout, stderr, exitCode }.withTempConfig({ profile }, cb)— writes./apyx.config.jsonand a hex key file into a unique tempdir, runscbwith{ 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 ofallowance(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 roughlypreviewDeposit(assets),apxUSD.balanceOf(depositor)shrank byassets,totalAssetsgrew. - 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.apyUSDRateViewisundefined(asserted at client-construction time — no network needed).
sdk/client.fork.spec.ts
createApyxClientwithhttpread transport + distinctwalletTransport— read + write go through different URLs, verified by pointing each at a different anvil instance on two ports.addressesoverride — passing a bogusapyUSDaddress surfaces a revert/zero-response path cleanly.UnsupportedChainErrorwhen 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 initwith piped stdin writes a valid file.apyx config show/pathreturn 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: onlyhash: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-urloverride 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 baseflip on a profile configured as ethereum → reads apyUSD on base addresses (tested on a Base fork spun up alongside).--key-pathoverride 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
LedgerAppHandlevia an exported factory hook. Runapyx apyUSD depositagainst 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')returnscreateApyxClient,APYX_ADDRESSES, ABIs; a smokehttp()+ mainnet read works. - a Vite + React consumer:
import { createApyxClient } from '@koed_jang/apyx-sdk'compiles, smoke test via vite-node.
- a Node CJS consumer:
npm install -gthe tarball →apyx --versionprints 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,totalAssetsall 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, runspnpm typecheck,pnpm test(unit-E2E),pnpm build, playground build. Stays fast (~1 min).e2e.yml(new) — runs onmain+ on PRs touchingsrc/**,example/src/**,abis/**. Installs Foundry viafoundry-rs/foundry-toolchain@v1, boots anvil forks as needed, runspnpm test:e2e. Time budget ~8 min.packaging.yml(new) — cron nightly + on release tag. Runspnpm pack+ the packaging specs. Catchesfiles/exportsmisconfigurations 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_NUMBERin 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)
chore/e2e-anvil-fixture— shared anvil spawn/teardown +giveApxUSDstorage-slot helper. Lands the fixture, no test specs yet.test/e2e-sdk-reads— per-contract read specs on top of the fixture.test/e2e-sdk-writes— deposit / approve / redeem write round-trips.test/e2e-cli-config-and-repl— config commands + REPL-via-stdin specs.test/e2e-cli-methods— driver overCONTRACT_COMMANDSfor every read + write.test/e2e-cli-session-flags— flag-override coverage.test/e2e-cli-ledger-mock— Ledger signer path with a mocked hw-app-eth.test/e2e-packaging-tarball—pnpm pack+ install-and-import smoke in CJS + ESM.test/e2e-playground-playwright— browser-side coverage.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
ERC20Upgradeablelayout 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
anvilis installed once; stillO(2×RAM). If CI slots are a constraint, merge the--chain baseoverride tests into a single fork cycle by reusing one anvil process with different fork-url restarts. - Playwright RPC stubbing — Playwright’s
Route.fulfilllets 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
- Tag format
- What CI does on tag push
- Why no
--provenance - Pre-flight checklist
- After publishing
- First-publish gotcha — spurious
latesttag - Cadence
- Related
Versioning policy
| Stage | Version pattern | npm dist-tag |
|---|---|---|
| Prototype (current) | 0.1.0-prototype.N | prototype |
| Stable (future) | 0.X.Y (no suffix) | latest |
| Future pre-releases | -rc.N / -beta.N / -alpha.N | rc / 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):
actions/checkout@v4at the tag’s commit.pnpm/action-setup@v4(pnpm 9),actions/setup-node@v4(Node 20) withregistry-url: https://registry.npmjs.org.pnpm install --frozen-lockfile.pnpm build— Rollup bundles + tsc declarations.- Derive the dist-tag (script above).
pnpm publish --no-git-checks --tag <derived>withNODE_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:e2eor 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 installto 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.
Related
- Repo layout — where the publish artifacts come from.
- Build and test — what
pnpm buildproduces. - Install — how consumers install whatever you just published.