@yellow-org/clearnet-sdk
v0.9.0
Published
TypeScript SDK for Clearnet integration.
Readme
Clearnet TypeScript SDK
TypeScript SDK for Clearnet integration. This package exposes a public gateway
client (balances, transfer history, EIP-712 transfers and operation recovery)
and EVM, Solana, XRPL, and Bitcoin vault depositors. EVM supports native ETH and ERC-20
deposits. Solana supports native SOL and SPL token deposits. XRPL supports
native XRP and issued-currency deposits. Bitcoin supports native BTC deposits.
Deposits credit a destination account. EVM, Solana, XRPL, and Bitcoin can
also carry an optional ADR-015 opaque reference.
The package is ESM-first. EVM callers use viem clients and primitives. Solana
and XRPL callers provide SDK-owned signer adapters around their wallet or local
keypair.
Install
npm install @yellow-org/clearnet-sdk viem xrpl @scure/btc-signerFor local development in this repository:
cd sdk/ts
npm ciSolana signer adapters use the @solana/web3.js v1 transaction API.
Import Solana client helpers from the SDK's solana namespace so transactions
and RPC clients share the same constructors. Mixing transactions with a
separately installed client can break methods such as simulateTransaction.
Gateway Client
GatewayClient is the TypeScript counterpart of the Go pkg/client used by
the yellow CLI. It reads balances and recent transfers, and prepares, signs
and submits EIP-712 transfers through a public gateway. It depends only on
viem; import it from the /gateway entry point to keep chain depositors out
of browser bundles.
import { GatewayClient, toBaseUnits } from "@yellow-org/clearnet-sdk/gateway";
const gateway = new GatewayClient({
baseUrl: "https://gateway.example.org",
networkId: "YTES", // the gateway must serve this network
// fingerprint: "…", // optional: pin the identity `yellow network show` prints
});
const balance = await gateway.balance(address); // live, includes the nonce
const page = await gateway.transfers(address, { limit: 20 }); // recent cache, cursor paging
const intent = await gateway.prepareTransfer({
from: address,
recipient: "0x…", // or yellow://ynet/user/0x…
assets: [{ asset: "USDC", amount: "125.5" }, { asset: "YELLOW", amount: "2" }],
maxFee: "0.5", // explicit YELLOW ceiling
});
// Any EIP-712 signer works: wagmi signTypedDataAsync, a viem WalletClient, or a local account.
const signed = await gateway.signTransfer(intent, (typedData) => walletClient.signTypedData({ account, ...typedData }));
const result = await gateway.submitTransfer(signed, { firstSubmission: true }); // sealed or pendingPrepared intents retain a catalogueFingerprint covering the signing domain,
asset decimals and backing-asset identities. Both signing and submission check
fresh metadata against it, including when the client has no configured trust pin.
Persist that field with signed intents. Missing or changed catalogue metadata
requires preparation and review again; this local guard does not change the
EIP-712 schema or revoke signatures already issued. The Go client uses the same
fingerprint through client.NetworkFingerprint.
Pool spotPrice is a base-unit reserve ratio multiplied by 10^18, not a human
price string. The optional transferPrice is the effective integer YELLOW
base-unit ratio used for transfer fees (EMA after bootstrap). Older gateways
without transferPrice need an explicit user-approved fee cap.
Wallets that enforce "domain chain ID equals active chain" (MetaMask) need the
Yellow chain active before signing. Its chain ID is networkChainId(networkId).
prepareTransfer resolves a pasted user URI to its account address before
signing. For an externally prepared Go intent that retains a canonical
yellow:// recipient, transferTypedData instead signs the first 20 bytes of
keccak256(uri) in the EIP-712 to slot, exactly like the Go SDK, while the
routed request keeps the original URI. If your app signs one of these raw URI
intents, show the full URI to the user before requesting the wallet signature:
the wallet displays the derived address, not the human-readable URI.
Submission never retries automatically: a timeout can happen after the network included the transfer. Persist the signed transfer before posting and follow the CLI's recovery rules, which are exported as pure functions:
import { applySubmission, markSubmitting, newOperationRecord, observeOperation, retryDecision } from "@yellow-org/clearnet-sdk/gateway";
let record = newOperationRecord(signed, gateway.baseUrl); // save it
const submitting = markSubmitting(record); // save it BEFORE the POST
try {
record = applySubmission(record, submitting, {
result: await gateway.submitTransfer(signed, { firstSubmission: record.submittedAt === undefined }),
});
} catch (error) {
record = applySubmission(record, submitting, { error }); // rejected, unresolved, or restored
}
// Later: observeOperation(record, await gateway.operation(record.id)).
// Replay record.signed only when retryDecision(record, status).allowed.Set firstSubmission only when the authorization has never been submitted.
Omitting it conservatively keeps POST errors unknown, including a stale-nonce
409 after an earlier execution. definiteRejection therefore applies only to
recognized rejections of a known first submission. A preflight submitted: false
describes this call only. Pass an abort signal as { signal }; the existing
submitTransfer(signed, signal) form also remains supported.
Before signing another transfer, check unexplainedRejectedOperation(records,
networkId, sender, prepared.nonce) as well as blockingOperation and
staleNonceOperation. An advanced nonce after a locally rejected authorization
needs reconciliation unless a local seal at that same nonce explains it. Call
await reconcileRejectedOperation(record, originalGatewayClient) for that
record, and persist the returned record under the account lock before signing.
If it becomes sealed, stop the replacement send and review the actual payment.
Otherwise nonceConsumedBy retains a different outgoing transfer sealed at the
exact sender nonce, so unexplainedRejectedOperation no longer blocks it. The
rejection and signed authorization remain intact. The web wallet performs this
automatically during preparation and again under its signing lock.
gateway.sealedTransferAtNonce(sender, nonce) searches up to 1,000 recent
activity entries. It relies on the trusted gateway's verified history index,
without verifying validator quorum locally. Missing, expired, unavailable or
inconsistent evidence keeps new sends blocked; ask the original gateway operator
to restore its verified index. An advanced nonce alone never clears the guard.
Amounts are exact decimal strings (toBaseUnits, fromBaseUnits,
formatBaseUnits); floats are never used. Errors are GatewayError (with a
kind), APIError (REST envelope) and SubmissionError (always carries the
operation ID; outcomeUnknown is never permission to re-sign).
Bitcoin Quick Start
Bitcoin deposits use BitcoinVaultDepositor. Native BTC uses
BITCOIN_NATIVE_ASSET, which is an empty string, and deposit amounts are
positive decimal BTC strings. The depositor spends from a P2WPKH address
derived from the signer public key and pays the one generic P2WSH deposit
address derived from the configured vault keys (ADR-023): every deposit pays
the same address, and destination.account is instead carried as a raw
20-byte address in a zero-value OP_RETURN marker output alongside it. The
SDK signs digest bytes through a caller-provided BitcoinSigner so local
keys, HSMs, or wallet adapters can live outside the core depositor.
import {
BITCOIN_NATIVE_ASSET,
BitcoinCoreRpcClient,
BitcoinVaultDepositor,
} from "@yellow-org/clearnet-sdk";
import {
pubECDSA,
randomPrivateKeyBytes,
signECDSA,
} from "@scure/btc-signer/utils.js";
import type { BitcoinSigner } from "@yellow-org/clearnet-sdk";
const privateKey = randomPrivateKeyBytes();
const signer: BitcoinSigner = {
algorithm: "secp256k1",
getPublicKeyCompressed() {
return pubECDSA(privateKey, true);
},
signDigest32(digest) {
return signECDSA(digest, privateKey);
},
};
const rpc = new BitcoinCoreRpcClient({
url: "http://127.0.0.1:18443",
username: "sdk",
password: "sdk",
wallet: "sdk",
});
const vaultPubkeys = [
pubECDSA(randomPrivateKeyBytes(), true),
pubECDSA(randomPrivateKeyBytes(), true),
pubECDSA(randomPrivateKeyBytes(), true),
];
const depositor = new BitcoinVaultDepositor({
network: "regtest",
rpc,
signer,
vaultPubkeys,
threshold: 2,
minFundingConfirmations: 1,
fallbackFeeRateSatPerVByte: 5n,
});
console.log(await depositor.depositorAddress()); // fund this P2WPKH address
console.log(depositor.depositAddress()); // the same generic address for every deposit
const result = await depositor.submitDeposit({
destination: { account: "000000000000000000000000000000000000a1a1" },
asset: BITCOIN_NATIVE_ASSET,
amount: "0.0002",
});
console.log(result.txHash); // display txid
console.log(result.depositId); // "<txid>:0"
console.log(await depositor.chainDepositStatus(result.txHash, result.depositId, 1));BitcoinCoreRpcClient sends Basic Auth only when both username and password
are supplied. Browser demos should use a same-origin proxy, omit credentials in
the client config, and inject Bitcoin Core credentials server-side.
EVM Quick Start
Native ETH deposits use EVM_NATIVE_ASSET, which is an empty string. Amounts
are positive decimal strings. Native ETH uses 18 decimals by default, or the
nativeDecimals override in depositor config; ERC-20 decimals are read from the
token contract and cached.
import {
ClearnetSdkError,
EVM_NATIVE_ASSET,
EvmVaultDepositor,
} from "@yellow-org/clearnet-sdk";
import {
createPublicClient,
createWalletClient,
custom,
http,
} from "viem";
import type { Address, EIP1193Provider } from "viem";
declare const window: Window & { ethereum?: EIP1193Provider };
const chainId = 31_337;
const rpcUrl = "http://127.0.0.1:8545";
// Replace with the Custody contract deployed on the configured chain.
const custodyAddress = "0x0000000000000000000000000000000000001000" as Address;
if (window.ethereum === undefined) {
throw new Error("No EIP-1193 wallet found");
}
const accounts = (await window.ethereum.request({
method: "eth_requestAccounts",
})) as Address[];
const walletAccount = accounts[0];
if (walletAccount === undefined) {
throw new Error("Wallet did not return an account");
}
const publicClient = createPublicClient({
transport: http(rpcUrl),
});
const walletClient = createWalletClient({
account: walletAccount,
transport: custom(window.ethereum),
});
const depositor = new EvmVaultDepositor({
publicClient,
walletClient,
walletAccount,
custodyAddress,
chainId,
});
try {
const result = await depositor.submitDeposit(
{
destination: { account: walletAccount },
asset: EVM_NATIVE_ASSET,
amount: "0.01",
},
{
onSubmitted(submitted) {
console.log("deposit submitted", submitted.txHash, submitted.depositId);
},
},
);
console.log("deposit mined", result.txHash, result.depositId);
console.log(
"status",
await depositor.chainDepositStatus(result.txHash, result.depositId, 1),
);
} catch (error) {
if (error instanceof ClearnetSdkError) {
console.error(error.code, error.txHash, error.depositId, error.step);
}
throw error;
}The SDK checks the configured public RPC chain and wallet chain before signing
or submitting a deposit. If either chain does not match chainId, it throws
CHAIN_MISMATCH.
EVM nonces
Every EVM deposit consumes the next 2D nonce for (depositor, nonceKey) — the
same key << 64 | sequence layout as the ERC-4337 EntryPoint. nonceKey
defaults to 0n; pass EvmSubmitDepositInput.nonceKey to use another key.
Each key has its own consecutive sequence, so one depositor address can keep
several independent deposit streams.
Router rule: a contract that deposits on behalf of many users should give each
user its own key (for example key = uint192(uint160(user)), as integration
rule 2 in custody's src/interfaces/IDeposit.sol recommends) and must take the
sequence from the user's own signed input, never read it from on-chain state
at execution time (otherwise, after a deep reorg, the replayed call can pick a
different nonce and be credited twice). submitDeposit reads getNonce
itself, so it is meant for an account that submits its own deposits, not for
building such a contract's calldata.
Do not call submitDeposit concurrently for the same (depositor, nonceKey) —
both calls would race the same on-chain counter and one would revert. Calling
it again after a previous call has already returned successfully is safe.
If submitDeposit throws after the nonce has been read, the error carries
step ("approve" or "deposit"), depositId, nonceKey, nonce, and
txHash when known (see Troubleshooting). Do not blindly
retry: a retry reads a fresh nonce and, if the earlier attempt actually landed
on chain, creates a second real deposit. Resolve the outcome first:
stepis"deposit"andtxHashis set: callchainDepositStatus(txHash, depositId, minConfirmations)."confirmed"or"pending"means the deposit exists;"absent"means that transaction did not produce it.- No
txHash(the wallet call itself failed, so viem returned no hash), orstepis"approve":chainDepositStatuscannot be used. Read the vault'sgetNonce(depositor, nonceKey)atlatest. If it is greater than the error'snonce, a deposit with that nonce has landed (this one, or another from the same depositor and key); otherwise the nonce is unused and callingsubmitDepositagain is safe. STALE_NONCE: the nonce this call read had already been consumed by another deposit, so this call created no deposit; callsubmitDepositagain, and it reads the now-current nonce.
After a successful mine, submitDeposit also checks that the receipt carries
the vault's Deposited log for the returned depositId; if it does not, it
throws DEPOSIT_EVENT_NOT_FOUND with txHash and depositId.
ERC-20 Deposits
For ERC-20 deposits, pass the token contract address as asset and the token
amount as a decimal string.
const result = await depositor.submitDeposit({
destination: { account: walletAccount },
asset: "0x0000000000000000000000000000000000003000",
amount: "25",
});The SDK reads and caches the token's decimals(), converts the decimal amount
to base units, and checks the current allowance. If it does not already cover
the amount, the SDK submits an exact-amount approve(custodyAddress, amount)
transaction and waits for it to mine before submitting the custody
deposit(...) transaction. When the allowance already covers the amount (for
example on a retry after an earlier approve landed), no approve is sent. An
allowance that is nonzero but below the amount still gets a
nonzero-to-nonzero approve, which tokens such as USDT reject; reset such a
token's allowance to zero first. A successful submitDeposit call returns the
deposit transaction's { txHash, depositId }, not the approval transaction's.
If an ERC-20 approval or deposit fails, the thrown ClearnetSdkError carries
step ("approve" or "deposit"), the deposit's identity fields, and
txHash when known — see EVM nonces.
Solana Deposits
Solana deposits use SolanaVaultDepositor. The SDK builds the custody
instruction and delegates signing/broadcast to a caller-provided SolanaSigner.
The signer boundary is small so browser-wallet, Wallet Standard, and local
keypair adapters can live outside the core SDK.
prepareDeposit(input) returns the same unsigned custody Transaction used by
submitDeposit. It does not set a recent blockhash or sign/broadcast. Clients
may use it for simulation, compute-budget selection, and fee estimation without
duplicating the SDK's custody instruction encoding.
import {
SOLANA_NATIVE_ASSET,
SolanaVaultDepositor,
solana,
type SolanaSigner,
} from "@yellow-org/clearnet-sdk";
const {
Connection,
Keypair,
LAMPORTS_PER_SOL,
sendAndConfirmTransaction,
} = solana;
const rpcUrl = "http://127.0.0.1:8899";
const keypair = Keypair.generate();
const connection = new Connection(rpcUrl, "confirmed");
const airdrop = await connection.requestAirdrop(
keypair.publicKey,
LAMPORTS_PER_SOL,
);
await connection.confirmTransaction(airdrop, "confirmed");
const signer: SolanaSigner = {
publicKey: keypair.publicKey.toBase58(),
async signAndSend(transaction) {
return sendAndConfirmTransaction(connection, transaction, [keypair], {
commitment: "confirmed",
preflightCommitment: "confirmed",
});
},
};
const depositor = new SolanaVaultDepositor({
rpcUrl,
signer,
commitment: "confirmed",
});
const result = await depositor.submitDeposit({
destination: {
account: "00000000000000000000000000000000000000a1",
ref: "0x3333333333333333333333333333333333333333333333333333333333333333",
},
asset: SOLANA_NATIVE_ASSET,
amount: "0.1",
});
console.log(result.txHash); // Solana base58 signature
console.log(result.depositId); // "0x<sha256(signature bytes)>:0"
console.log(await depositor.chainDepositStatus(result.txHash, result.depositId, 0));Native SOL uses SOLANA_NATIVE_ASSET, which is an empty string. For SPL
deposits, pass the mint public key as asset and the token amount as a decimal
string. The SDK reads and caches SPL mint decimals. It does not mint tokens or
create token accounts. SPL callers must ensure the depositor ATA and vault ATA
exist before submitting the deposit.
XRPL Deposits
XRPL deposits use XrplVaultDepositor. Amounts are positive decimal strings.
Native XRP uses an empty asset string. Issued-currency assets use CUR.rIssuer
and require decimals in depositor config.
import {
XRPL_NATIVE_ASSET,
XrplVaultDepositor,
} from "@yellow-org/clearnet-sdk";
import { Wallet, hashes, type SubmittableTransaction } from "xrpl";
import type {
XrplPreparedPayment,
XrplSigner,
} from "@yellow-org/clearnet-sdk";
const wallet = Wallet.generate();
const signer: XrplSigner = {
classicAddress: wallet.classicAddress,
async sign(payment: XrplPreparedPayment) {
const signed = wallet.sign(payment as SubmittableTransaction);
return { txBlob: signed.tx_blob, hash: hashes.hashSignedTx(signed.tx_blob) };
},
};
const depositor = new XrplVaultDepositor({
rpcUrl: "ws://127.0.0.1:6006",
vaultAddress: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh",
signer,
});
try {
const result = await depositor.submitDeposit({
destination: {
account: "00000000000000000000000000000000000000a1",
ref: "0x3333333333333333333333333333333333333333333333333333333333333333",
},
asset: XRPL_NATIVE_ASSET,
amount: "1",
});
console.log(result.txHash); // uppercase XRPL transaction hash
console.log(result.depositId); // the lower-cased hash
console.log(await depositor.chainDepositStatus(result.txHash, result.depositId, 0));
} finally {
await depositor.disconnect();
}For issued currencies, pass the asset key, decimal amount, and configured decimals:
const usdAsset = "USD.rN7n7otQDd6FczFgLdSqtcsAUxDkw6fzRH";
const depositor = new XrplVaultDepositor({
rpcUrl: "ws://127.0.0.1:6006",
vaultAddress: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh",
signer,
issuedAssetDecimals: { [usdAsset]: 2 },
});
const ref = await depositor.submitDeposit({
destination: { account: "00000000000000000000000000000000000000a1" },
asset: usdAsset,
amount: "25.50",
});Trustlines and balances must already exist before an issued-currency deposit.
The SDK builds one XRPL Payment, adds one ynet-account memo carrying the
Clearnet account/reference, asks the caller-provided signer to sign, submits the
signed blob, and returns after rippled accepts the submit result as tesSUCCESS
or terQUEUED. Use chainDepositStatus to observe validated-ledger finality; a
just-submitted XRPL payment can return pending until it appears in a validated
ledger.
prepareDeposit(input) returns the same autofilled unsigned Payment used by
submitDeposit. Callers can inspect its Fee, Sequence, and
LastLedgerSequence before signing; submitDeposit remains the authoritative
SDK signing and submission flow.
Deposit References
Pass destination.ref to attach a 32-byte opaque sub-account reference to the
deposit. Omit it when there is no sub-account reference.
const result = await depositor.submitDeposit({
destination: {
account: walletAccount,
ref: "0x3333333333333333333333333333333333333333333333333333333333333333",
},
asset: EVM_NATIVE_ASSET,
amount: "0.01",
});For EVM, the reference is passed to Custody.deposit(...) as bytes32. For
Solana, it is encoded into deposit_sol or deposit_spl as [u8; 32]. For
XRPL, it is appended after the 20-byte Clearnet account in the ynet-account
payment memo. The SDK does not interpret it. Omitted references are sent as 32
zero bytes. For Bitcoin, a non-zero reference selects the ADR-023 version
0x02 deposit marker (the OP_RETURN output carries the account and the
reference); an omitted or all-zero reference selects version 0x01, which
carries no reference on the wire.
Verify A Deposit
const status = await depositor.chainDepositStatus(
result.txHash,
result.depositId,
1,
);chainDepositStatus is a pure on-chain read — it does not check
clearing/crediting on Clearnet, only whether the deposit transaction itself is
present and final. It does not apply custody's crediting rules (for example
the Bitcoin dust floor and self-deposit guard, or XRPL partial payments and
asset support), so "confirmed" does not guarantee the deposit will be
credited. It returns:
| Status | Meaning |
|---|---|
| confirmed | The transaction has a successful receipt and at least minConfirmations confirmations. |
| pending | The transaction is known but is not confirmed enough yet. |
| absent | The transaction is unknown or has a reverted receipt. |
minConfirmations accepts a non-negative safe integer number or a non-negative
bigint. EVM treats it as an inclusive receipt confirmation count. Solana maps
it onto the commitment ladder: 0 accepts confirmed; >= 1 requires
finalized. XRPL validates the shape for cross-chain parity but treats XRPL
finality as binary: a validated transaction is confirmed. Bitcoin treats 0
as 1, so a mempool transaction is never confirmed; it returns pending for
mempool or shallow transactions and confirmed when the transaction has at
least minConfirmations (at least one) confirmations.
API Reference
EvmVaultDepositor
new EvmVaultDepositor(config: EvmDepositorConfig)Config fields:
| Field | Type | Notes |
|---|---|---|
| publicClient | viem PublicClient | Used for chain checks, receipt waits, and verification. |
| walletClient | viem WalletClient | Used to submit approval and deposit transactions. |
| walletAccount | Account \| Address | Must match walletClient.account when the wallet client has one. |
| custodyAddress | Address | The deployed custody contract address. |
| chainId | number | Positive safe integer; must match the public RPC and wallet chain. |
| receiptTimeoutMs | number | Optional default timeout for receipt waits. |
| nativeDecimals | number | Optional native ETH decimals override; defaults to 18. |
submitDeposit(input, options?)
Input fields:
| Field | Type | Notes |
|---|---|---|
| destination.account | Address | Clearnet account credited by the custody deposit. |
| destination.ref | Hash \| undefined | Optional 32-byte opaque reference. Omitted values are sent as bytes32(0). |
| asset | Address \| "" | Use EVM_NATIVE_ASSET for native ETH, or an ERC-20 token address. |
| amount | string | Positive decimal amount. Native uses nativeDecimals; ERC-20 tokens use on-chain decimals(). |
| nonceKey | bigint \| undefined | Optional upper 192 bits of the 2D deposit nonce, in [0, 2^192) (otherwise INVALID_INPUT); defaults to 0n. See EVM nonces. |
Options:
| Field | Notes |
|---|---|
| signal | Aborts the receipt wait. |
| receiptTimeoutMs | Overrides the receipt wait timeout for this call. |
| onSubmitted | Called once with the deposit's SubmitDepositResult (see below), after the deposit transaction mines. |
Returns:
interface SubmitDepositResult {
txHash: string;
depositId: string;
}txHash is the mined deposit transaction's hash. depositId is "0x" +
64 lowercase hex characters — the ID custody signs a MintReceipt for; it is
exported as depositId(chainId, vault, depositor, nonce) from
@yellow-org/clearnet-sdk, alongside requireDepositId, composeNonce, and
splitNonce. Pass both to chainDepositStatus.
BitcoinVaultDepositor
new BitcoinVaultDepositor(config: BitcoinDepositorConfig)Config fields:
| Field | Type | Notes |
|---|---|---|
| network | "mainnet" \| "testnet" \| "signet" \| "regtest" | Selects address parameters. |
| rpc | BitcoinRpc | Used for UTXO lookup, fee estimates, broadcast, and verification. |
| signer | BitcoinSigner | Provides a compressed secp256k1 public key and DER signature over 32-byte digests. |
| vaultPubkeys | (Uint8Array \| string)[] | Compressed secp256k1 vault public keys. The SDK sorts them before building the P2WSH script. |
| threshold | number | Multisig threshold, from 1 to the number of vault keys. |
| minFundingConfirmations | bigint \| number | Optional UTXO confirmation floor; defaults to 1. |
| feeTargetBlocks | bigint \| number | Optional estimatesmartfee target; defaults to 6. |
| fallbackFeeRateSatPerVByte | bigint \| number | Optional fallback fee rate; defaults to 5. |
Bitcoin input fields:
| Field | Type | Notes |
|---|---|---|
| destination.account | string | 20-byte hex Clearnet account address. Every deposit pays the one generic P2WSH address (depositAddress()); the account is instead carried in the deposit's OP_RETURN marker output. |
| destination.ref | `0x${string}` \| undefined | Optional 32-byte opaque reference. A non-zero value selects the ADR-023 version 0x02 marker; an omitted or all-zero value selects version 0x01. |
| asset | string | Use BITCOIN_NATIVE_ASSET, the empty string. Other asset values are rejected. |
| amount | string | Positive decimal BTC amount that fits in signed 64-bit satoshis. |
For Bitcoin, SubmitDepositResult.txHash is the display txid, and
depositId is "<txid>:<vout>" with the decimal output index — the ID custody
signs a MintReceipt for. The SDK-built deposit output is always output 0, so
submitDeposit and submitSignedDepositPsbt return
bitcoinDepositId(txHash, 0); bitcoinDepositId(txid, vout) is exported from
@yellow-org/clearnet-sdk. It keeps the txid's case as given; pass the
lowercase hex txid Bitcoin Core reports. submitSignedDepositPsbt throws
INVALID_INPUT without broadcasting unless output 0 of the signed transaction
pays depositAddress() the amount in expectedOutputs[0]. submitDeposit
returns after Bitcoin Core accepts the raw transaction; use
chainDepositStatus to observe mempool, shallow, and confirmed states.
Bitcoin chainDepositStatus throws INVALID_DEPOSIT_ID unless depositId is
exactly bitcoinDepositId(txHash, vout). It reports "absent" unless the
transaction is known, output vout pays depositAddress() a positive value,
and the transaction's outputs carry exactly one valid ADR-023 deposit marker.
BitcoinRpc.getRawTransaction must therefore return the transaction's
outputs (value in satoshis and scriptPubKey hex, in output order);
BitcoinCoreRpcClient reads them from verbose getrawtransaction. The dust
floor and self-deposit guard custody applies before crediting are not checked.
For PSBT wallet signing, prepareDepositPsbt returns an unsignedTxID for the
unsigned transaction shape. Use the txHash returned by
submitSignedDepositPsbt for verification because wallet finalization can
change the final txid for nested-SegWit inputs.
BitcoinCoreRpcClient is a convenience adapter for Bitcoin Core JSON-RPC:
new BitcoinCoreRpcClient({
url: "http://127.0.0.1:18443",
username: "sdk",
password: "sdk",
wallet: "sdk",
});Omit username and password together when using a same-origin proxy that adds
Basic Auth outside the browser.
SolanaVaultDepositor
new SolanaVaultDepositor(config: SolanaDepositorConfig)Config fields:
| Field | Type | Notes |
|---|---|---|
| rpcUrl | string | Used for signature-status verification and commitment waits. |
| signer | SolanaSigner | Provides publicKey and signAndSend(transaction). |
| programId | string | Optional Solana custody program public key; defaults to the SDK's bundled custody program ID. |
| commitment | "processed" \| "confirmed" \| "finalized" | Optional; defaults to finalized. |
| receiptTimeoutMs | number | Optional default timeout for commitment waits. |
Solana input fields:
| Field | Type | Notes |
|---|---|---|
| destination.account | string | 20-byte Clearnet account as hex, optional 0x, or URI-like value whose final path segment is that hex. |
| destination.ref | `0x${string}` \| undefined | Optional 32-byte opaque reference. |
| asset | string | Use SOLANA_NATIVE_ASSET, the empty string, or an SPL mint public key. |
| amount | string | Positive decimal amount. Native SOL uses 9 decimals; SPL tokens use mint decimals. |
For Solana, SubmitDepositResult.txHash is the base58 signature, and
depositId is "0x" + lowercase hex of sha256(the 64 signature bytes) + ":" +
index, where index is the Deposited event's zero-based position among the
transaction's custody Deposited and Executed event CPIs. A transaction
submitDeposit builds carries one deposit, so it returns
solanaDepositId(signature, 0); solanaDepositId(signature, index) is
exported from @yellow-org/clearnet-sdk.
Solana chainDepositStatus throws INVALID_DEPOSIT_ID unless depositId is
exactly solanaDepositId(txHash, index). It keeps the signature-status
commitment mapping, and for a confirmed or finalized status also fetches
the transaction: a failed transaction, or one whose custody event at index
is not a Deposited event, is "absent"; a transaction the RPC does not
serve yet is "pending".
const transaction = await depositor.prepareDeposit(input);
// Add a recent blockhash and any client-owned compute-budget instructions,
// then simulate or call getFeeForMessage. submitDeposit still performs the
// authoritative SDK submission flow.XrplVaultDepositor
new XrplVaultDepositor(config: XrplDepositorConfig)Config fields:
| Field | Type | Notes |
|---|---|---|
| rpcUrl | string | XRPL WebSocket URL used for autofill, submit, and verification. |
| vaultAddress | string | XRPL classic address that receives deposits. |
| signer | XrplSigner | Provides classicAddress and sign(payment). |
| maxFeeDrops | bigint \| number | Optional positive fee ceiling checked after autofill and before signing. |
| issuedAssetDecimals | Record<string, number> | Required per issued-currency asset used for deposits. Native XRP uses 6 decimals. |
XRPL input fields:
| Field | Type | Notes |
|---|---|---|
| destination.account | string | 20-byte Clearnet account as hex, with optional 0x. |
| destination.ref | `0x${string}` \| undefined | Optional 32-byte opaque reference. |
| asset | string | Empty string for native, or issued-currency CUR.rIssuer. |
| amount | string | Positive decimal amount; native XRP uses 6 decimals, issued currencies use configured decimals. |
For XRPL, SubmitDepositResult.txHash is the uppercase 64-hex transaction
hash, and depositId is that hash lower-cased — the ID custody signs a
MintReceipt for; xrplDepositId(txHash) is exported from
@yellow-org/clearnet-sdk.
XRPL chainDepositStatus throws INVALID_DEPOSIT_ID unless depositId is
xrplDepositId(txHash). It reports "absent" unless the transaction is a
Payment from another account to vaultAddress whose first well-formed
ynet-account memo names a non-zero 20-byte account followed by a 32-byte
reference. Such a transaction is "pending" until validated, then
"confirmed" if its result is tesSUCCESS and "absent" otherwise. Partial
payments and asset support, which custody checks before crediting, are not
checked.
const payment = await depositor.prepareDeposit(input);
console.log(payment.Fee); // autofilled network fee in dropsXrplVaultDepositor owns an XRPL WebSocket client. Call
await depositor.disconnect() when the depositor is no longer needed, such as
when replacing the signer or shutting down a long-lived process.
chainDepositStatus(txHash, depositId, minConfirmations)
Returns Promise<"absent" | "pending" | "confirmed">. A pure on-chain read —
it does not check clearing/crediting on Clearnet, and "confirmed" does not
guarantee the deposit will be credited.
Local Development
Run package checks from sdk/ts:
npm run typecheck
npm test
npm run build
npm --workspace @yellow-org/evm-deposit-demo run build
npm --workspace @yellow-org/solana-deposit-demo run build
npm --workspace @yellow-org/xrpl-deposit-demo run build
npm --workspace @yellow-org/bitcoin-deposit-demo run buildnpm test discovers every unit and example test under test/, excluding
*.integration.test.ts. The test:core, test:evm, test:sol, test:xrpl,
and test:btc scripts run focused subsets. The test:integration:* scripts
select Vitest's integration mode, which discovers only integration tests.
Run the EVM integration test against local Anvil:
# From the repository root:
make devnet-evm
# From sdk/ts:
npm run test:integration:evm
# From the repository root:
make devnet-downThe integration test deploys fresh Custody and MockERC20 contracts on each
run.
Run the Bitcoin integration test against local Bitcoin Core regtest:
# From the repository root:
make devnet-btc
# From sdk/ts:
npm run test:integration:btc
# From the repository root:
make devnet-downThe Bitcoin integration test creates a fresh signer set, mines regtest funds, funds the generated depositor P2WPKH address, submits a native BTC deposit, and verifies the returned transaction reference before and after mining.
Run the Solana integration test against the local validator:
# From the repository root:
make devnet-sol
# From sdk/ts:
npm run test:integration:sol
# From the repository root:
make devnet-downThe Solana devnet preloads the custody program at
98eVpih8X9CAcgU9bzNB9V7VtkRrnFZUmqzEnsq7cfmg. The integration test creates
and funds local signers, creates SPL token accounts needed for the test, submits
native SOL and SPL deposits, and verifies each returned transaction reference.
Run the XRPL integration test against local rippled:
# From the repository root:
make devnet-xrpl
# From sdk/ts:
npm run test:integration:xrpl
# From the repository root:
make devnet-downThe XRPL integration test uses rippled WebSocket ws://127.0.0.1:6006 for SDK
calls and admin JSON-RPC http://127.0.0.1:5005 for ledger_accept. Override
with XRPL_WS_URL and XRPL_ADMIN_RPC_URL if needed. It creates fresh accounts,
funds them from the standalone genesis wallet, submits native XRP and issued
currency deposits, and verifies each returned transaction reference.
To run the repository integration suite, including the TS EVM, Solana, XRPL, and Bitcoin integration tests:
# From the repository root:
make devnet
make integration
make devnet-downDemo
Start the browser demo from sdk/ts:
npm run demo:evm
npm run demo:sol
npm run demo:xrpl
npm run demo:btcThe EVM demo expects:
- an EIP-1193 wallet, such as MetaMask
- an RPC URL and chain ID that match the wallet's selected network
- a deployed
Custodycontract address - a funded wallet account on that network
make devnet-evm starts Anvil on http://127.0.0.1:8545 with chain ID 31337,
but it does not predeploy Custody for the browser demo. See
examples/evm-deposit/README.md for the
browser flow, required contract setup, and troubleshooting notes.
The Solana demo discovers wallets through Wallet Standard, uses
solana:signTransaction, and broadcasts the signed transaction through the
configured RPC URL. The selected wallet chain must be one the wallet advertises,
such as solana:localnet for a local validator. The local devnet preloads the
custody program, but the wallet must be funded and SPL token accounts must
already exist for SPL deposits. See
examples/solana-deposit/README.md for
local funding notes and troubleshooting.
The XRPL demo supports a local signer for standalone-devnet smoke tests and
GemWallet for browser-wallet signing. The GemWallet path requires a custom
wss:// endpoint that points at the same local chain as the demo because wallet
network selection and SDK submission must agree. See
examples/xrpl-deposit/README.md for the full
local signer flow, GemWallet custom-network setup, funding steps, and
troubleshooting notes.
The Bitcoin demo uses local generated keys and Bitcoin Core regtest through a
Vite proxy at /btc-rpc. Start make devnet-btc, then npm run demo:btc.
The browser never receives the Bitcoin Core username or password; the Vite proxy
adds Basic Auth before forwarding to http://127.0.0.1:18443. The demo includes
both a deterministic local-signer flow and an Xverse PSBT signing flow; see
examples/bitcoin-deposit/README.md for the
Xverse Electrs facade, local funding steps, and troubleshooting notes.
Troubleshooting
Errors thrown by the SDK use ClearnetSdkError with a stable code.
| Code | Common cause |
|---|---|
| INVALID_INPUT | Submit options are missing or have the wrong shape, an asset is unsupported, or Bitcoin transaction/PSBT finalization input is invalid. |
| INVALID_ADDRESS | EVM address, Solana public key, Solana mint, program ID, XRPL classic address, XRPL issued-currency key, or Clearnet account is invalid. |
| INVALID_AMOUNT | amount is not positive, has the wrong type/precision, or exceeds the chain limit (uint256 for EVM, uint64 for Solana/XRPL native drops, signed 64-bit satoshis for Bitcoin). |
| INVALID_CONFIRMATIONS | minConfirmations is negative, fractional, or an unsafe number. |
| INVALID_REFERENCE | destination.ref is not a 32-byte hex value. |
| INVALID_TX_ID | txHash is not valid for the chain: EVM transaction hash, Solana 64-byte signature, XRPL 64-hex hash, or Bitcoin 64-hex txid. |
| INVALID_DEPOSIT_ID | depositId is not a valid deposit ID: for EVM, exactly "0x" + 64 lowercase hex characters (rejects a txHash/logIndex shape); for Bitcoin, Solana and XRPL, not exactly the chain's deposit ID helper output for txHash. |
| MISSING_WALLET_ACCOUNT | The EVM wallet account is missing/mismatched, or the Solana/XRPL signer is missing. |
| CHAIN_MISMATCH | The configured chain or network does not match the RPC or wallet network, such as an EVM chain ID mismatch or unsupported Bitcoin network. |
| INSUFFICIENT_FUNDS | Bitcoin only: confirmed depositor UTXOs cannot cover the deposit amount plus fee. |
| TX_REVERTED | A submitted approval/deposit transaction reverted, or XRPL rejected the payment engine result. |
| RECEIPT_TIMEOUT | Waiting for a receipt timed out or was aborted. |
| STALE_NONCE | EVM only: the deposit reverted because the nonce read at the start of submitDeposit was already consumed by another deposit from the same (depositor, nonceKey). Call submitDeposit again — it reads the then-current nonce. |
| DEPOSIT_EVENT_NOT_FOUND | EVM only: the deposit transaction mined successfully, but its receipt has no Deposited log from the configured vault whose depositor and nonce reproduce the expected depositId (for example a wrong custodyAddress or chainId). Carries txHash and depositId. |
| RPC_ERROR | The public RPC or wallet provider returned an unexpected error. |
When a transaction may already have been submitted, ClearnetSdkError can
include txHash (the on-chain transaction/signature hash, when known) and,
for EVM, depositId, nonceKey and nonce (known before broadcast) and
step ("approve" or "deposit"). Use txHash to let a user inspect the
submitted transaction, and the other fields to decide whether it is safe to
retry — see EVM nonces.
