@molpha/sdk
v0.2.0
Published
Molpha Consumer SDK — subscribe, request signed data from the gateway, submit attestations and read on-chain feeds.
Maintainers
Readme
@molpha/sdk
TypeScript SDK for Molpha, a pull-based oracle protocol. Molpha nodes fetch an API result, sign it once with a threshold signature, and the same signed attestation can be verified on Solana, EVM and Starknet. This SDK is for data consumers and feed owners, and runs in the browser and in Node.js.
Links: molpha.io · GitHub · npm
Use it to:
- subscribe to a Molpha plan on Solana;
- derive a source id from an API config;
- request a threshold-signed attestation from the gateway;
- submit the signed result on-chain (
submit_attestation); - verify/read the latest feed value;
- build EVM and Starknet verifier arguments from the same signed result.
Runtime: Node.js >=20.19.0 (ESM).
Protocol model
Molpha turns off-chain API responses into verified on-chain data.
At a high level:
Consumer
└─ subscribes (USDC) on Solana
└─ derives sourceId = keccak256(canonical apiConfig)
└─ signs a RequestAuth bound to (programId, gateway, sourceId, signaturesRequired, authTimestamp)
Gateway
└─ assigns the round's timestamp (unix ms) from its own clock and coordinates a signing round
Verifier nodes
└─ fetch/recompute the API result independently
└─ sign the canonical result if valid
Solana / EVM / Starknet verifiers
└─ verify quorum, registry version, signer bitmap, timestamp, and aggregate signature
└─ finalize or expose the verified valueThe gateway is a coordination layer, not a trusted oracle. A result is trusted only if it carries a valid threshold signature from the selected verifier nodes for the current registry version.
Molpha uses Solana as the canonical protocol chain for subscriptions, registry snapshots, node accounts, and feed state. EVM and Starknet verifier contracts are stateless verification surfaces: they verify signed Molpha attestations without managing subscriptions or source configuration locally.
Every signed attestation commits to the same message across chains:
message = keccak256(
keccak256("MOLPHA_MESSAGE_V1") || value || sourceId || u32be(registryVersion) ||
u8(signaturesRequired) || u64be(timestamp) || signersBitmap
)The preimage is 141 bytes (32 + 32 + 32 + 4 + 1 + 8 + 32).
attestationMessageHash / attestationMessageHashFromAttestation recompute it client-side.
Install
npm install @molpha/sdk @anchor-lang/core bn.js
# or: pnpm add @molpha/sdk @anchor-lang/core bn.js
# or: yarn add @molpha/sdk @anchor-lang/core bn.js@anchor-lang/core and bn.js are optional peer dependencies. Install them if you use the Solana / Anchor path (as in the examples below). Gateway-only or EVM/Starknet-only apps can skip them. Runtime dependencies installed automatically are @solana/kit, @noble/* and canonicalize.
For the pre-release channel (the dev gateway and the matching dev Solana program; may change without notice):
npm install @molpha/sdk@devnpm tags and deployment defaults
Defaults are baked at publish time from config/deployments.json (MOLPHA_SDK_PROFILE). Override at runtime with endpoints, programId, and idl.
| npm dist-tag | Profile | Default gateway | Solana program | EVM / Starknet |
|---|---|---|---|---|
| latest | stable | https://gateway.molpha.io/ | MoLF3pgVA9MENSuEujxNNSMaTo71aLyewaH5nrWfUZs | Documented CREATE2 / Sepolia addresses |
| dev | dev | https://dev-gateway.molpha.io/ | chivcFQgxzwkpLvW41PV431HQ4dYpaW3povQH3AdpQt | Provisional until redeploy (MOLPHA_DEV_VERIFIERS_PROVISIONAL); falls back to stable addresses |
Inspect the active profile via MOLPHA_SDK_PROFILE and MOLPHA_DEV_VERIFIERS_PROVISIONAL. Local builds: MOLPHA_SDK_PROFILE=stable pnpm build or MOLPHA_SDK_PROFILE=dev pnpm build (default dev).
| Import | Use |
|---|---|
| @molpha/sdk | Facade (MolphaSDK), MolphaGateway, MolphaSolanaClient, core hashing (deriveSourceId, attestationMessageHash, hashRequestAuth, timestampSeconds), EVM/Starknet helpers. Browser-safe; no fs in the main entry. |
| @molpha/sdk/utils | walletFromKeypairFile, loadKeypair — load a Solana CLI keypair as an Anchor Wallet. Node.js only. |
The package is ESM with "sideEffects": false, so gateway-only or read-only apps can tree-shake unused paths.
Quick start
import { web3 } from "@anchor-lang/core";
import { MolphaSDK, PlanType, deriveSourceIdString } from "@molpha/sdk";
import { walletFromKeypairFile } from "@molpha/sdk/utils";
const wallet = walletFromKeypairFile("~/.config/solana/id.json");
const sdk = new MolphaSDK({
connection: new web3.Connection("https://api.devnet.solana.com", "confirmed"),
wallet,
// endpoints: [{ url: "https://gateway.example.com", gatewayAuthority: "<base58>" }],
});
// Subscribe if needed (USDC on Solana)
const plan = await sdk.solana.getPlan(PlanType.Basic);
await sdk.solana.subscribe(PlanType.Basic, {
maxPriceUsdc: plan.subscriptionPrice,
});
const apiConfig = {
url: "https://api.example.com/price",
responseParser: "$.price",
};
const signaturesRequired = 3;
const { result, signature, feed } = await sdk.requestAndSubmit({
apiConfig,
signaturesRequired,
});
// The round's identity, for reads and cross-chain verification.
const sourceId = deriveSourceIdString(apiConfig); // === result.payload.sourceIdrequestAndSubmit requests a threshold-signed attestation from the gateway (against the current on-chain registry version) and submits it to Solana via submit_attestation in one call. The first successful submit creates the feed account for (sourceId, signaturesRequired, submitter) if it does not already exist.
Configuration
Required
| Option | Description |
|---|---|
| connection | Anchor-compatible Solana RPC connection. |
| wallet | MolphaWallet. Used for Solana transactions and gateway authentication when available. |
Optional
| Option | Default |
|---|---|
| endpoints | DEFAULT_GATEWAY_ENDPOINT — URL, { url, gatewayAuthority }, or an array of either for failover (see Gateway identity) |
| programId | MOLPHA_PROGRAM_ADDRESS from the vendored IDL; also bound into gateway request auth |
| idl | MOLPHA_IDL from idl/molpha.json |
| commitment | "confirmed" |
import {
DEFAULT_GATEWAY_ENDPOINT,
MOLPHA_IDL,
MOLPHA_PROGRAM_ADDRESS,
} from "@molpha/sdk";
const sdk = new MolphaSDK({
connection,
wallet,
endpoints: [
DEFAULT_GATEWAY_ENDPOINT,
{ url: "https://backup.example.com", gatewayAuthority: "<gateway base58 pubkey>" },
],
// programId: "YourProgramAddress...",
// idl: MOLPHA_IDL,
});Gateway identity
Gateway request authorization binds the gateway's on-chain account, so the client must know which gateway it is talking to:
requestAuthHash = keccak256(
"MOLPHA_REQAUTH_V1" || programId || gatewayPda || sourceId || u8(signaturesRequired) || u64le(authTimestamp)
)
gatewayPda = PDA(["molpha_gateway", gatewayAuthority], programId)authTimestamp is the caller's unix seconds at signing: a freshness stamp for the authorization only (the gateway rejects stamps outside its request_auth.window_seconds, 60 s by default). It is read from the SDK clock for every attempt, so a retry after a 409 signs a fresh one. It has nothing to do with the round's timestamp.
Pass gatewayAuthority (the gateway's base58 signing pubkey) per endpoint to pin it. When omitted, the SDK calls GET {url}/v1/info once per endpoint and reads:
{ "status": "ok", "data": { "gatewayAuthority": "<base58>", "programId": "<base58>" } }A programId that differs from the client's is rejected. Because the hash differs per gateway, the auth signature is recomputed for every endpoint actually tried during failover — with a browser wallet that means one signing prompt per endpoint tried. /v1/info is never contacted when no signer is configured (dev zero-signature path).
Round timestamp
The request body carries authSig and authTimestamp but no round timestamp. The gateway stamps the
round with its own clock, in unix milliseconds, floored to a fixed 100 ms tick
(floor(nowMs / 100) * 100), and the result carries the stamp in payload.timestamp. The tick is a
protocol constant, exported as ROUND_TICK_MS: it is the same on every gateway and node, it is not
configurable, and GET /v1/info does not report it. Stamping does not wait for a tick boundary, so
it adds no latency. Committee selection reads only the timestamp's one-second window (ten ticks), so
nobody can pick a committee by picking a time.
- One feed (a source, a quorum and a registry version) runs at most 10 rounds per second, whatever the request rate.
- Requests for the same feed inside one tick share one round: the nodes run it once and every caller gets the result. Each caller still spends its own unit of round quota, or its own payment.
- One consumer gets one round per tick per feed, so at most 10 per second. A second request by the same consumer for the same source and quorum inside the tick is refused with HTTP 409; the SDK retries it after one tick.
- The message the nodes sign is
keccak256(MOLPHA_MESSAGE_V1 || value || sourceId || u32be(registryVersion) || u8(quorum) || u64be(timestamp) || signersBitmap). - Chain clocks, epochs and
maxAge/staleness are in seconds: compare withtimestampSeconds(payload.timestamp)(flooredts / 1000).timestampAgeSeconds(ts, nowSeconds)saturates at 0. - The SDK returns
payload.timestampexactly as the gateway gave it. Nodes enforce the grid when they sign; the on-chain verifiers do not check it.
Retries and timeouts
requestSignedData makes up to maxRetries attempts (default 6) and chooses the wait before each by why the last one failed (retryDelayMs). Every wait carries jitter so clients that failed together do not retry together. The backoff step is a capped exponential: 250 ms, 500 ms, up to 5 s.
| Last attempt | Wait before the next |
| --- | --- |
| 409 (this consumer already has a round for the source and quorum in the current tick) | one tick (ROUND_TICK_MS, 100 ms), plus up to 20 ms |
| 503 or 429 (gateway at capacity, or too few nodes accepted the round) | the later of the gateway's Retry-After and the backoff step, plus up to one more step |
| timeout, network error, other 5xx | the backoff step, half of it randomized |
| 400, 401, 402, 403 | none: terminal. 403 means the subscription is inactive or its round quota for the term is spent |
A 400 saying the registryVersion is not the current one (a cached context, or a registry roll between your read and the request) is the exception: the SDK reads the registry afresh and retries once, without spending an attempt.
The wait after a 409 is a full tick rather than the time left to the next boundary: a full tick puts the retry in a later tick whatever the offset between the client's clock and the gateway's, so the SDK never computes a boundary from the local clock. A caller polling one feed gains nothing by asking more often than once per tick: space the requests at least ROUND_TICK_MS apart.
A retry is a new request, and the gateway stamps it anew, so it is a new round: it spends another unit of round quota or needs another payment, and with a paywalled source another set of payment authorizations. Nothing ties a retry to the attempt before it; there is no idempotency key. Set maxRetries: 1 to make a single attempt and decide about a retry yourself.
The gateway limits the total load: at capacity it answers HTTP 503 with a Retry-After, and a consumer over its request rate gets HTTP 429. It also answers 503 when too few nodes accept a round. The SDK treats them alike: it backs off and retries, and throws a GatewayError with the status when the attempts run out.
timeoutMs defaults to 35 s, above the gateway's own wait for a round (roundTimeoutSeconds in /v1/info, 30 s by default). A shorter timeout abandons a round the gateway is still running, and the retry then starts another. A source that cannot be fetched is reported as soon as enough nodes have failed (usually well under a second), not after the wait.
Wallet
wallet is a single MolphaWallet used across both protocol surfaces:
| Layer | What it signs |
|---|---|
| Solana client | Transactions such as subscribe, extendSubscription, submitAttestation |
| Gateway client | hashRequestAuth({ programId, gateway, sourceId, signaturesRequired, authTimestamp }) for authenticated gateway requests |
Gateway auth is resolved automatically when you use MolphaSDK:
- Use
wallet.signAuthMessageif provided. - Else derive signing from Anchor
Wallet.payerwhen the secret key is available, such as withwalletFromKeypairFile. - Else omit auth and use an all-zero
authSig.
MolphaSDK passes the resolved signer to sdk.gateway as its default, so
sdk.gateway.requestSignedData({ apiConfig, signaturesRequired }) authenticates without an
explicit signer. Standalone new MolphaGateway(...) omits auth unless you pass
a defaultSigner (third constructor arg) or per-call signer.
The all-zero authSig path is for development only: a gateway rejects it with 401. Production jobs should authenticate gateway requests.
Node.js utility
import { walletFromKeypairFile } from "@molpha/sdk/utils";
const wallet = walletFromKeypairFile("~/.config/solana/id.json");Browser wallet adapter
import type { MolphaWallet } from "@molpha/sdk";
const wallet: MolphaWallet = {
publicKey: adapter.publicKey,
signTransaction: (tx) => adapter.signTransaction(tx),
signAllTransactions: (txs) => adapter.signAllTransactions(txs),
signAuthMessage: async (msg) => new Uint8Array(await adapter.signMessage(msg)),
};You can also override gateway auth per call with gateway.requestSignedData({ ..., signer }) or with the same field in requestAndSubmit.
Core flow
Use MolphaSDK for the end-to-end path, or use MolphaSolanaClient / MolphaGateway separately when you only need one side.
import { web3 } from "@anchor-lang/core";
import {
MolphaSDK,
PlanType,
deriveSourceIdString,
} from "@molpha/sdk";
import { walletFromKeypairFile } from "@molpha/sdk/utils";
const sdk = new MolphaSDK({
connection: new web3.Connection("https://api.devnet.solana.com", "confirmed"),
wallet: walletFromKeypairFile("~/.config/solana/id.json"),
});1. Subscribe
Subscriptions are paid in USDC on Solana.
For local/dev testing on Solana Devnet, you can request test USDC from Circle's faucet: https://faucet.circle.com/ (select USDC on Solana Devnet).
const plan = await sdk.solana.getPlan(PlanType.Basic);
// Show plan.subscriptionPrice to the user before charging.
const { pricePaid } = await sdk.solana.subscribe(PlanType.Basic, {
maxPriceUsdc: plan.subscriptionPrice,
});maxPriceUsdc is a safety bound. The transaction aborts if the live plan price is higher than the amount the user approved.
2. Source id
There is no create-feed instruction. A data source is identified by its canonical API config:
sourceId = keccak256(JSON.stringify(canonicalizeAPIConfig(apiConfig)))const apiConfig = {
url: "https://api.example.com/price",
responseParser: "$.price",
};
const sourceId = deriveSourceIdString(apiConfig); // 64 hex chars, no 0xThe canonical JSON has the fixed key order url, method, headers (sorted by UTF-16 code units, {} if empty), responseParser, valueTransform and, for tolerance mode, a trailing aggregation. The key is omitted for exact mode, so existing five-field configs keep their sourceId.
The gateway, the Solana program and the EVM/Starknet verifiers all recompute sourceId from the same canonical config, so pass the same apiConfig (including {{secret.*}} placeholders) every time. signaturesRequired is not part of sourceId: on Solana a feed account is keyed by (sourceId, signaturesRequired, submitter), so the same source can be tracked at different quorums.
3. Request signed data from the gateway
const signaturesRequired = 3;
const result = await sdk.gateway.requestSignedData({
apiConfig,
signaturesRequired,
});The gateway round uses the current on-chain registry version. Selected verifier nodes independently fetch/recompute the result and sign only if the observed value matches the canonical result.
The returned Attestation matches the cross-VM struct (payload + signature), plus gateway-only value (human-readable) and fresh. payload carries sourceId, the signed 32-byte value, timestamp, registryVersion, and signaturesRequired; signature carries the aggregate Schnorr material and signersBitmap. signaturesRequired must be at least the protocol's min_signers (currently 3) or the chain rejects the submit.
A gateway response is a coordination result, not proof of settlement or of on-chain verification. Consumers still decide freshness, source, quorum and replay policy.
4. Submit on Solana
const { signature, feed } = await sdk.solana.submitAttestation(result);submit_attestation takes { attestation: { payload, signature }, rawValue, coalitionKey } plus one read-only Node account per signer (ascending signer-bit order, resolved from the registry snapshot the round was signed against). The SDK builds all of it: it fetches the signer Node accounts in one batched read, sums their secp256k1 keys into the affine coalition key (computeCoalitionKey), and checks the program's signer-count bounds before sending. The coalition key is unsigned instruction data that the program checks projectively against its own sum, so a wrong key only fails the transaction. Pass { coalitionKey } to skip the Node fetch when you already hold it.
If the signed 32-byte value is the keccak digest of a longer preimage, pass { rawValue } (up to 256 bytes). The SDK verifies the digest before sending:
const { signature, feed } = await sdk.solana.submitAttestation(result, { rawValue });Compute budget and fees. The transaction requests estimateSubmitComputeUnits(signers) compute units (about 43k + 9.2k per signer, plus 15% and 10k of margin: roughly 140k at 8 signers), not the 1.4M maximum the old default asked for, which mattered once a priority fee is priced per requested unit. Pass { computeUnitLimit } to override it. No priority fee is attached unless you ask: { priorityFeeMicroLamports: 2_000 } sets a price in micro-lamports per unit, and { priorityFeeMicroLamports: "auto" } uses the 75th percentile of the fees recently paid by writers of that feed (capped at 1 lamport per unit; an unreadable fee market means no fee). Use one of them when submits are dropped under load.
Many feeds. The client caches what feeds sharing a registry version have in common: the registry read (30 s), each signer Node key (never changes) and the coalition key of each signer set, and concurrent submits share one read. Submitting a fleet of feeds therefore costs one registry read and one Node read per distinct signer, not per submit. A stale or duplicate round is refused by the program in about 17-20k compute units, before it verifies the signature.
Then read the feed this wallet wrote for that source and quorum:
const feedState = await sdk.solana.readFeed(result.payload.sourceId, signaturesRequired);FeedAccount.value is the 32 signed bytes (valueKind.value) or their keccak preimage hash (valueKind.hash); submitter is the wallet that created the feed.
The subscription read does not expose usage: the program stores no used_rounds / prepaid_usdc / price, and round quota is counted by the gateway's off-chain outbox. SubscriptionInfo carries only owner, planType, validUntil, maxRounds, delegateCount, maxDelegates, maxSigners.
One-call request + submit
const { result, signature, feed } = await sdk.requestAndSubmit({
apiConfig,
signaturesRequired,
});This is equivalent to:
const result = await sdk.gateway.requestSignedData({ apiConfig, signaturesRequired });
const { signature, feed } = await sdk.solana.submitAttestation(result);If the round succeeds but submitting fails, requestAndSubmit throws a SubmitFailedError that carries the signed attestation (error.result). The round already consumed quota and the attestation is valid, so submit error.result again instead of requesting a new round.
Many feeds
For a large set of feeds use requestMany (also exported from the package root) instead of looping over requestAndSubmit:
import { requestMany } from "@molpha/sdk";
const results = await requestMany(sdk, feeds, {
spreadMs: 1000, // spread starts by source hash so feeds due together do not arrive together
request: { maxRetries: 6 },
});
for (const r of results) {
if (!r.ok) console.error(r.label, r.error);
else if (r.submitError) console.warn(r.label, "round ok, submit failed", r.submitError);
}It reads the registry inputs once for the whole batch; bounds concurrency, starting from half the gateway's advertised maxInflightRounds (at most 64, else 32) and adapting (halving on a busy answer, growing back while requests succeed); runs Solana submits on their own smaller limiter (submitConcurrency, default 8); never discards the signed attestation of a round whose submit failed; and returns one result per feed in input order, so a failing feed does not stop the others. Pass submit: false to collect attestations only. Throughput is limited by the submit path (one transaction per attestation) long before it is limited by the gateway.
Batching attestations of one feed
At a high update rate, submit several attestations of the same feed in one transaction:
const { signature, count } = await sdk.solana.submitAttestations([older, newer, newest]);Each becomes its own submit_attestation instruction, sorted by timestamp, and instructions run in
the order they are listed, so every attestation is applied, oldest first. Separate transactions give
no such order. Sent a few hundred milliseconds apart they reach a leader in a different order often
enough (about a third at 250 ms spacing in a devnet run) that the older one is refused with
FeedNotNewer; a batch makes the spacing between transactions several times longer, and one
transaction costs one fee.
- All attestations must be for one feed (the same
sourceIdandsignaturesRequired), with distinct timestamps. They may differ in registry version and signer set. - The transaction is atomic. If a newer attestation of the feed has already landed, every instruction is stale and none applies, so batch what is due together and send batches of one feed in order.
- A transaction holds 1232 bytes, which is about three attestations with three signers, two with
four or five.
maxAttestationsPerTransaction(signerCount, { distinctSigners })estimates it, and a batch that does not fit throwsBatchTooLargeErrorbefore sending, withfitssaying how many go: submit those and the rest apart. A priority-fee instruction costs bytes too. rawValueis not supported in a batch; usesubmitAttestationfor values longer than 32 bytes.- An attestation waits for the others in its batch, so a batch of
nmade at an interval oftdelays the oldest by(n - 1) * t.
Fast requests with a cached context
By default every requestSignedData call reads the on-chain registry up front
(current version, redundancy buffer and node count of that snapshot) and fetches
the gateway node set only when the round needs it (private API encryption). When
you run many rounds for the same source, fetch these once and reuse them so each
round is a single gateway POST.
// Fetch registryVersion + redundancyBuffer + nodeCount + nodes once.
const context = await sdk.gateway.prepareContext();
// Reuse it across rounds — no prelude fetches.
const result = await sdk.gateway.requestSignedData({ apiConfig, signaturesRequired, context });context is a Partial<RoundContext>, so you can cache only what you have
and let requestSignedData fetch the rest:
const result = await sdk.gateway.requestSignedData({
apiConfig,
signaturesRequired,
context: { nodes }, // registryVersion + redundancyBuffer + nodeCount still fetched fresh
});Caching is opt-in because these inputs can drift. A stale registryVersion,
redundancyBuffer, nodeCount, or node set yields a result the chain will reject —
refresh the context when the on-chain registry changes. The SDK derives the
selection from the on-chain nodeCount and refuses a cached node list whose length
disagrees with it. The same context field is accepted by requestAndSubmit.
Median tolerance mode
By default every signing node must observe the identical value. For noisy numeric sources
(prices), add an aggregation object and the selected nodes instead exchange signed
observations, drop stale ones and outliers, and sign the lower median of
signaturesRequired surviving observations:
const apiConfig = {
url: "https://api.example.com/price",
responseParser: "$.price",
aggregation: {
mode: "tolerance",
rule: "median",
maxDeviationBps: 50, // u32: allowed distance from the lower median, in bps
maxAgeMs: 2000, // > 0: max observation age in milliseconds
numeric: { type: "int256", decimals: 8 }, // value = round-half-even(price * 10^8)
},
};
const { result } = await sdk.requestAndSubmit({ apiConfig, signaturesRequired: 5 });Rules (the node is the reference; the SDK rejects violations before any request is made, with AggregationConfigError):
aggregationis part of thesourceIdand is omitted for exact mode.mode: "exact"is rejected because it would change the identity.- Only
rule: "median"andnumeric.type: "int256"are supported.decimalsis0..255,maxDeviationBpsa u32,maxAgeMsa positive integer. - Tolerance needs
signaturesRequired >= 3. - The nested object is rebuilt in canonical order (
mode,rule,maxDeviationBps,maxAgeMs,numeric{type, decimals}); extra or reordered keys on your input never reach the hash. For the config above,sourceIdhashes exactly{"url":...,"method":"GET","headers":{},"responseParser":"$.price","valueTransform":"","aggregation":{"mode":"tolerance","rule":"median","maxDeviationBps":50,"maxAgeMs":2000,"numeric":{"type":"int256","decimals":8}}}(pinned against the node's Go testTestAggregationSourceIdentity). - The signed value is a signed
int256(two's-complementbytes32). For tolerance resultsAttestation.valueis rendered from the signedpayload.valueat the source'sdecimals, andsignature.signersBitmap/ signature fields must come from the response, since the nodes choose the final signing set after the observation exchange. - The gateway must forward
aggregationto the nodes. If it derives a different identity, the response'ssourceId/configHashwill not match and the SDK throws; a gateway that echoesaggregationin its response must echo the requested policy.
Helpers for the signed value (mirror the node's new/tolerance package):
import {
encodeInt256Decimal, // ("42150.12345678", 8) -> 32-byte Uint8Array, round half to even, bounds-checked
decodeInt256, // bytes32 (Uint8Array | hex) -> bigint
formatInt256Decimal, // bytes32 or bigint, decimals -> "42150.12345678" (trailing zeros trimmed)
encodeInt256, // bigint -> bytes32
INT256_MIN,
INT256_MAX,
} from "@molpha/sdk";
const feedState = await sdk.solana.readFeed(result.payload.sourceId, 5);
const price = formatInt256Decimal(Uint8Array.from(feedState!.value), 8);Private APIs and encrypted secrets
Sources can use private APIs without sending plaintext secrets to the gateway.
const result = await sdk.gateway.requestSignedData({
apiConfig: {
url: "https://api.example.com/private-price?key={{secret.apiKey}}",
responseParser: "$.price",
},
signaturesRequired,
encrypt: {
secrets: {
apiKey: process.env.API_KEY!,
},
},
});MolphaSDK wires verifyNodeKeys to solana.verifyNodeKeysForPrivateApi, which authenticates gateway node encryption keys against the on-chain Node accounts of the round's registry snapshot (registry.nodes[index]) before secrets are encrypted.
Secrets are encrypted into per-node envelopes. The gateway coordinates the round but should not receive plaintext API credentials. Secrets are encrypted for every node of the registry (the committee is unknown until the gateway stamps the round), and the gateway forwards only the selected nodes' envelopes. verifyNodeKeys therefore authenticates all of them. The encrypted plaintext is the canonical config (including aggregation) with secrets substituted.
Private API access is still an active security-sensitive surface. Do not treat encrypted secret delivery as production-ready until gateway/node-side test vectors and validation are complete.
Paywalled API sources
Some API sources answer an unpaid request with HTTP 402 instead of data. You pay such a source directly, from your own wallet on the source's network. Molpha never holds, signs for, or converts those funds, and the Molpha side of the round is unchanged: a paid source still costs exactly one round of subscription quota.
import { createEvmSignerFromPrivateKey } from "@molpha/sdk";
const result = await sdk.gateway.requestSignedData({
apiConfig,
signaturesRequired,
sourcePayment: {
signer: createEvmSignerFromPrivateKey(process.env.EVM_PRIVATE_KEY!),
},
});The SDK fetches the source unpaid to read its terms, signs one EIP-3009 transfer
authorization per node in the round's eligible set, and sends them as sourcePayments.
Beta signs exact payments in USDC on Base and Base Sepolia only; any other network or
asset is rejected before you sign anything. Private keys stay in your process — pass your
own EvmSigner to keep them in a wallet or KMS instead.
You pay per node fetch, not per datum. Independent fetching is the point of the
protocol, so one round costs up to
min(signaturesRequired + redundancyBuffer, nodeCount) source calls — the same eligible
set the selection bitmap is drawn from. Only authorizations a node actually spends ever
settle, so unused ones cost nothing. Sign the whole set anyway: a short set starves buffer
nodes and makes the round more likely to fail.
Each retry signs fresh authorizations with new nonces, because a dispatched round cannot be replayed and a retry is therefore a new round. Authorizations the source already settled stay spent if the round then fails; unsettled ones expire worthless. There is no refund path and none is needed.
Source payment is per-round access material, like a credential. It never enters
sourceId, so a paid and an unpaid fetch of the same API config share one source
identity.
Without a source wallet
A paywalled source with no sourcePayment throws UpstreamPaymentRequiredError, whose
quote carries the resource, the eligible set size, and the source's own rejection
detail. This is terminal — the round is never retried blindly.
import { UpstreamPaymentRequiredError } from "@molpha/sdk";
try {
await sdk.gateway.requestSignedData({ apiConfig, signaturesRequired });
} catch (err) {
if (err instanceof UpstreamPaymentRequiredError) {
console.log(`${err.quote.resource} needs ${err.quote.eligibleSetSize} paid fetches`);
}
}To drive the payment yourself, probeSource, signSourcePayments and eligibleSetSize
are exported for x402-native agents calling /v1/x402/execute with their own client.
gateway.getNodesInfo() reports the gateway's advisory view of the registry policy that
sizes the eligible set, for callers with no Solana connection.
EVM verification
After a gateway round, the same signed result can be verified on EVM chains.
The SDK ships deployed testnet verifier addresses and framework-agnostic tuple builders. It does not depend on ethers or viem at runtime.
Deployed verifier address
The verifier is deployed with CREATE2 so the contract address is the same on every supported EVM chain.
import { MOLPHA_VERIFIER_ADDRESS } from "@molpha/sdk";
const address = MOLPHA_VERIFIER_ADDRESS;Supported network ids (selection helpers only): evm-sepolia, arbitrum-sepolia, avalanche-fuji, bsc-testnet.
Build verifier arguments
The verifier's entrypoint is
verify(Attestation attestation, uint64 maxAge) returns (bool success, uint8 code).
import { buildEvmVerifierArgs } from "@molpha/sdk";
const result = await sdk.gateway.requestSignedData({ apiConfig, signaturesRequired });
const { attestation, maxAge } = buildEvmVerifierArgs(result, { maxAge: 300 });maxAge is required. It is the freshness window in seconds: the verifier reports an older
attestation as STALE, and one dated after block.timestamp as MALFORMED. 0 disables the
check entirely — pass it only when your contract enforces freshness or ordering itself, because
a stateless verifier otherwise accepts a correctly signed attestation forever.
The generated object matches the Solidity IVerifier.Attestation struct, using viem's
primitive types so it passes straight into readContract or an ethers Contract. Member
order is ABI order (and the signed message's order). The gateway Attestation uses the same
nested shape with lowercase hex strings instead of viem primitives:
attestation:
{
payload: {
value: `0x${string}`, // bytes32 (Attestation.payload.value)
sourceId: `0x${string}`, // bytes32
registryVersion: number, // uint32
signaturesRequired: number, // uint8
timestamp: bigint, // uint64
},
signature: {
signature: `0x${string}`, // bytes32 (Attestation.signature.s)
commitment: `0x${string}`, // address (Attestation.signature.commitmentAddr)
signersBitmap: bigint, // uint256
},
}
maxAge: bigint // uint64The builder range-checks every integer against its Solidity type. Out-of-range calldata never
reaches verify: the ABI decoder reverts on it instead of returning a result code.
viem
import { createPublicClient, http } from "viem";
import {
buildEvmVerifierArgs,
MOLPHA_VERIFIER_ABI,
MOLPHA_VERIFIER_ADDRESS,
parseEvmVerifyResult,
} from "@molpha/sdk";
const client = createPublicClient({ chain, transport: http() });
const { attestation, maxAge } = buildEvmVerifierArgs(result, { maxAge: 300 });
const returned = await client.readContract({
address: MOLPHA_VERIFIER_ADDRESS,
abi: MOLPHA_VERIFIER_ABI,
functionName: "verify",
args: [attestation, maxAge],
});
const { success, code, reason } = parseEvmVerifyResult(returned);
// { success: true, code: 0, reason: "OK" }
// { success: false, code: 10, reason: "STALE" }ethers
import { Contract } from "ethers";
import {
buildEvmVerifierArgs,
MOLPHA_VERIFIER_ABI,
MOLPHA_VERIFIER_ADDRESS,
parseEvmVerifyResult,
} from "@molpha/sdk";
const verifier = new Contract(MOLPHA_VERIFIER_ADDRESS, MOLPHA_VERIFIER_ABI, provider);
const { attestation, maxAge } = buildEvmVerifierArgs(result, { maxAge: 300 });
const { success, code, reason } = parseEvmVerifyResult(await verifier.verify(attestation, maxAge));Raw eth_call
encodeEvmVerifyCalldata produces the full calldata (selector 0x67e2907b plus nine static
words), and parseEvmVerifyResult also accepts the raw 64-byte return data:
import { buildEvmVerifierArgs, encodeEvmVerifyCalldata, parseEvmVerifyResult } from "@molpha/sdk";
const args = buildEvmVerifierArgs(result, { maxAge: 300 });
const returnData = await provider.call({ to: verifierAddress, data: encodeEvmVerifyCalldata(args) });
const { success, code, reason } = parseEvmVerifyResult(returnData);verify never reverts; a rejection is a result code from the shared VERIFY_CODES table (see
the Starknet section for the full list). The EVM verifier
returns MALFORMED for a zero signaturesRequired, a zero or out-of-range signature scalar, a
zero commitment, or fewer set bitmap bits than signaturesRequired. parseEvmVerifyResult
throws when success and code disagree, which means the call did not reach a Molpha verifier
of this interface.
Registry reads
MOLPHA_VERIFIER_ABI also covers every read-only registry view — getRegistryVersion,
getTotalNodes, redundancyBuffer, getRegistryRoot / getRegistryPointer (current or per
version), activatesAt, retiredAt, isLatestVersion, nodeStatus, isNode — and the
InvalidRegistryVersion error the per-version views revert with. Owner-only mutators are not
included.
Lower-level helpers are also exported for manual integrations:
import {
toFixedHex,
signersBitmapToUint256,
signersBitmapToDecimal,
} from "@molpha/sdk";Starknet verification
After a gateway round, the same signed result can be verified on Starknet.
The SDK ships deployed testnet verifier addresses and framework-agnostic struct
builders. It does not depend on starknet.js at runtime.
Deployed verifier addresses
import {
MOLPHA_VERIFIER_STARKNET_ADDRESSES,
MOLPHA_VERIFIER_STARKNET_SEPOLIA,
getMolphaStarknetVerifierAddress,
} from "@molpha/sdk";
const address = getMolphaStarknetVerifierAddress("starknet-sepolia");
// or:
const sepolia = MOLPHA_VERIFIER_STARKNET_ADDRESSES["starknet-sepolia"];
const sepoliaDirect = MOLPHA_VERIFIER_STARKNET_SEPOLIA;| Network | Constant |
|---|---|
| Starknet Sepolia | MOLPHA_VERIFIER_STARKNET_SEPOLIA |
Build verifier arguments
The verifier's entrypoint is verify(attestation: Attestation, max_age: u64) -> (bool, u8).
import { buildStarknetVerifierArgs } from "@molpha/sdk";
const result = await sdk.gateway.requestSignedData({ apiConfig, signaturesRequired });
const { attestation, maxAge } = buildStarknetVerifierArgs(result, { maxAge: 300 });maxAge is required. It is the freshness window in seconds: the verifier reports an older
attestation as STALE. 0 disables the check entirely — pass it only when your contract
enforces freshness or ordering itself, because a stateless verifier otherwise accepts a
correctly signed attestation forever.
The generated object matches the Cairo Attestation struct. Member order is Cairo Serde
order (and the signed message's order). The gateway Attestation uses the same nested shape
with lowercase hex strings instead of Cairo felts:
attestation:
{
payload: {
value: u256,
source_id: u256,
registry_version: u32,
signatures_required: u8,
timestamp: u64,
},
signature: {
signature: u256,
commitment: felt252, // 20-byte address as felt
signers_bitmap: u256,
},
}The builder range-checks every integer against its Cairo type. Out-of-range calldata never
reaches verify: Cairo Serde fails while decoding the arguments and the call reverts
instead of returning a result code.
Call verify and read the result
encodeStarknetVerifyCalldata flattens the arguments into the 13 felts a raw starknet_call
takes, and parseStarknetVerifyResult decodes the (bool, u8) it returns. Any Starknet
client works; with starknet.js:
import { RpcProvider } from "starknet";
import {
buildStarknetVerifierArgs,
encodeStarknetVerifyCalldata,
parseStarknetVerifyResult,
} from "@molpha/sdk";
const provider = new RpcProvider({ nodeUrl: STARKNET_RPC_URL });
const args = buildStarknetVerifierArgs(result, { maxAge: 300 });
const response = await provider.callContract({
contractAddress: verifierAddress,
entrypoint: "verify",
calldata: encodeStarknetVerifyCalldata(args),
});
const { success, code, reason } = parseStarknetVerifyResult(response);
// { success: true, code: 0, reason: "OK" }
// { success: false, code: 10, reason: "STALE" }verify never reverts on well-formed calldata; a rejection is a result code. The codes are
shared with the EVM verifier contract and exported as VERIFY_CODES:
| Code | Name | Meaning |
|---|---|---|
| 0 | OK | Verified |
| 2 | BAD_REGISTRY_VERSION | registryVersion does not exist on this verifier |
| 3 | MALFORMED | Structurally invalid input, or dated in the future when maxAge != 0 |
| 4 | NOT_YET_ACTIVE | timestamp predates the registry version's activation |
| 5 | VERSION_EXPIRED | Registry version superseded more than the grace window earlier |
| 7 | BAD_QUORUM | Signers are not within the round's derived selection group |
| 8 | BAD_AGGREGATE | The signers' aggregate key is the point at infinity |
| 9 | BAD_SIGNATURE | The aggregate Schnorr signature does not verify |
| 10 | STALE | Older than maxAge |
Codes 1 and 6 are reserved and never returned. Codes are append-only, so
parseStarknetVerifyResult reports a code newer than your SDK as reason: "UNKNOWN" rather
than throwing.
Lower-level helpers are also exported:
import {
commitmentAddressToStarknetFelt,
signersBitmapToStarknetUint256,
verifyCodeName,
} from "@molpha/sdk";What verification checks
A Molpha attestation is valid only if the verifier can confirm:
- the update targets the expected
sourceId; - the result was signed against a specific
registryVersion(an immutable node-set snapshot); - the quorum satisfies
signaturesRequired; - the signer bitmap is a subset of the deterministic selection for
(sourceId, registryVersion, timestamp); - the aggregate Schnorr signature over
attestationMessageHash(...)is valid; - the timestamp is within the accepted freshness bounds;
- on Solana, the signer
Nodeaccounts passed as remaining accounts are exactlyregistry.nodes[bit]for every set bit, and the supplied coalition key matches the sum of their keys.
A valid signature does not replace consumer policy: freshness, source, quorum and replay checks remain the consumer's responsibility.
Solana verification finalizes feed state via submit_attestation. EVM and Starknet verification are stateless and return whether the signed Molpha attestation is valid for the deployed verifier registry.
IDL vendoring
The Solana client needs the Anchor IDL for the Molpha program.
A vendored copy ships under idl/ and is used by default:
import { MOLPHA_IDL, MOLPHA_PROGRAM_ADDRESS } from "@molpha/sdk";Override idl and programId when targeting another deployment.
The vendored IDL is anchor idl build -p molpha output for molpha-solana-program 3d01170 ("Epoch settlements (#47)").
Keep the vendored IDL aligned with the deployed program. Mismatched IDL/program versions can produce invalid account derivations, decoding errors, or failed instruction simulation.
Standalone clients
MolphaSDK is a convenience facade.
You can also use the lower-level clients directly:
import {
MolphaGateway,
MolphaSolanaClient,
gatewaySignerFromWallet,
} from "@molpha/sdk";
const solana = MolphaSolanaClient.create({
connection,
wallet,
});
const gateway = new MolphaGateway(
endpoints,
() => solana.getRegistrySelectionConfig(),
gatewaySignerFromWallet(wallet),
{
defaultSubscriptionOwner: wallet.publicKey.toBase58(),
verifyNodeKeys: (args) => solana.verifyNodeKeysForPrivateApi(args),
},
);The facade wires the registry selection config resolver, gateway signer, subscription owner, and node-key verifier automatically.
Status
Pre-1.0. The latest dist-tag is 0.1.0 (stable profile). Newer features described in this README (for example tolerance mode, requestMany and paywalled API sources) may be available only on the dev tag until the next stable release. See CHANGELOG.md.
Current scope:
- Solana subscription and extend flow;
- deterministic source id and attestation message hashing;
- gateway signed-data requests (failover, retries with backoff, per-gateway request auth, context cache);
- Solana attestation submission and feed/registry reads;
- private API encryption helpers (pre-production);
- caller-funded x402 payments for paywalled API sources (Base USDC, pre-production);
- EVM and Starknet verifier argument building,
verifycalldata encoding and result decoding; - deployed testnet verifier address helpers.
Known limitations:
- private API envelope encryption still needs gateway/node-side test-vector validation;
- paid-source payments sign
exactUSDC on Base and Base Sepolia only, and are pending end-to-end validation against a live paywalled source; - verifier-node registration and admin tooling are intentionally outside this package;
- production deployments should use authenticated gateway requests;
- testnet verifier addresses may change between protocol releases.
Solana paths such as selection bitmap and submit_attestation remaining-accounts resolution are aligned with the IDL selected by the active deployment profile (idl/profiles/{stable,dev}/molpha.json).
Develop
pnpm install
pnpm typecheck
pnpm test
pnpm build
pnpm e2e-demoReleasing
Versioning and publishing are automated with Changesets.
Versions follow semver and are driven by the nature of each change, not by the branch it merges from.
Workflow
Add a changeset with your change:
pnpm changesetChoose:
patchfor fixes;minorfor features;majorfor breaking changes.
While the package is pre-
1.0.0, useminorfor breaking changes andpatchfor features/fixes. Only selectmajorwhen intentionally cutting1.0.0.Stable releases from
mainWhen changes land on
main, the release workflow opens a release PR that bumpspackage.jsonand updatesCHANGELOG.md.Merging that PR publishes to npm on the
latesttag:npm install @molpha/sdkPrereleases from
devPushes to
devpublish a snapshot version on thedevdist-tag, for example:0.2.0-dev-<timestamp>Install with:
npm install @molpha/sdk@devRequires at least one pending changeset.
One-time setup
Configure npm trusted publishing for
@molpha/sdk:npmjs.com → @molpha/sdk → Settings → Trusted publishingAdd a trusted publisher for the release workflow:
| Workflow file | Branches | Purpose | | ------------- | -------------- | ------------------------------- | |
release.yml|main,dev| Stable releases + dev snapshots |Use organization
Molpha, repositorysdk, and the exact workflow filename (including.yml). NoNPM_TOKENsecret is required.Publish a stable release from
mainfirst.npm assigns the first published version to the
latesttag regardless of--tag. If adevsnapshot is published before any stable release, that prerelease can becomelatest.The
devworkflow should guard against this and fail until a stablelatestexists.
If latest ever points to a prerelease, repoint it after publishing a stable version:
npm dist-tag add @molpha/sdk@<stable-version> latest