From 1a6e213602e3cf3735584c5b46774cba90b52898 Mon Sep 17 00:00:00 2001 From: Maharshi Mishra Date: Thu, 25 Jun 2026 13:02:24 +0530 Subject: [PATCH 1/3] feat(ts): add xrpl vault depositor --- .github/workflows/test-ts.yml | 4 + Makefile | 9 +- devnet/README.md | 11 +- devnet/rippled.cfg | 3 + sdk/ts/README.md | 146 +++++- sdk/ts/examples/xrpl-deposit/index.html | 173 +++++++ sdk/ts/examples/xrpl-deposit/package.json | 19 + sdk/ts/examples/xrpl-deposit/src/main.ts | 195 ++++++++ sdk/ts/examples/xrpl-deposit/tsconfig.json | 8 + sdk/ts/package-lock.json | 198 +++++++- sdk/ts/package.json | 10 +- sdk/ts/src/blockchain/xrpl/constants.ts | 3 + sdk/ts/src/blockchain/xrpl/depositor.ts | 181 ++++++++ sdk/ts/src/blockchain/xrpl/encoding.ts | 23 + sdk/ts/src/blockchain/xrpl/index.ts | 14 + sdk/ts/src/blockchain/xrpl/types.ts | 52 +++ sdk/ts/src/blockchain/xrpl/validation.ts | 274 +++++++++++ sdk/ts/src/core/types.ts | 6 +- sdk/ts/src/index.ts | 16 + .../xrpl/depositor.integration.test.ts | 255 ++++++++++ sdk/ts/test/blockchain/xrpl/depositor.test.ts | 439 ++++++++++++++++++ 21 files changed, 2015 insertions(+), 24 deletions(-) create mode 100644 sdk/ts/examples/xrpl-deposit/index.html create mode 100644 sdk/ts/examples/xrpl-deposit/package.json create mode 100644 sdk/ts/examples/xrpl-deposit/src/main.ts create mode 100644 sdk/ts/examples/xrpl-deposit/tsconfig.json create mode 100644 sdk/ts/src/blockchain/xrpl/constants.ts create mode 100644 sdk/ts/src/blockchain/xrpl/depositor.ts create mode 100644 sdk/ts/src/blockchain/xrpl/encoding.ts create mode 100644 sdk/ts/src/blockchain/xrpl/index.ts create mode 100644 sdk/ts/src/blockchain/xrpl/types.ts create mode 100644 sdk/ts/src/blockchain/xrpl/validation.ts create mode 100644 sdk/ts/test/blockchain/xrpl/depositor.integration.test.ts create mode 100644 sdk/ts/test/blockchain/xrpl/depositor.test.ts diff --git a/.github/workflows/test-ts.yml b/.github/workflows/test-ts.yml index f4472b7..5122ad6 100644 --- a/.github/workflows/test-ts.yml +++ b/.github/workflows/test-ts.yml @@ -43,3 +43,7 @@ jobs: - name: Build Solana demo run: npm --workspace @yellow-org/solana-deposit-demo run build working-directory: sdk/ts + + - name: Build XRPL demo + run: npm --workspace @yellow-org/xrpl-deposit-demo run build + working-directory: sdk/ts diff --git a/Makefile b/Makefile index d0c312b..24eb5d0 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build lint test generate devnet devnet-evm devnet-sol devnet-down ts-deps integration +.PHONY: build lint test generate devnet devnet-evm devnet-sol devnet-xrpl devnet-down ts-deps integration build: go build ./... @@ -31,6 +31,10 @@ devnet-sol: docker compose -f devnet/docker-compose.yml up -d solana go run ./devnet/wait --networks solana +devnet-xrpl: + docker compose -f devnet/docker-compose.yml up -d rippled + go run ./devnet/wait --networks rippled + devnet-down: docker compose -f devnet/docker-compose.yml down -v @@ -38,8 +42,9 @@ ts-deps: npm --prefix sdk/ts ci # Blockchain flow tests against the devnet. Go tests cover deposit + withdrawal -# per chain; the TS suite covers EVM and Solana deposits. See devnet/README.md. +# per chain; the TS suite covers EVM, Solana, and XRPL deposits. See devnet/README.md. integration: ts-deps go test -tags integration ./pkg/blockchain/... -v npm --prefix sdk/ts run test:integration:evm npm --prefix sdk/ts run test:integration:sol + npm --prefix sdk/ts run test:integration:xrpl diff --git a/devnet/README.md b/devnet/README.md index 721e2b4..3cdc36c 100644 --- a/devnet/README.md +++ b/devnet/README.md @@ -15,7 +15,7 @@ the same `make integration` target. ```sh make devnet # anvil + bitcoind + rippled + solana-test-validator; blocks until all answer RPC npm --prefix sdk/ts ci -make integration # Go blockchain integrations + TS EVM and Solana integration +make integration # Go blockchain integrations + TS EVM, Solana, and XRPL integration make devnet-down ``` @@ -39,7 +39,10 @@ wallet, the XRPL genesis master). - **XRPL** — funds a fresh vault + depositor from the genesis master, `SignerListSet`s the vault over fresh signer keys, `TicketCreate`s a ticket, then deposits and runs the quorum withdrawal. Standalone rippled does not - auto-close ledgers, so the test calls `ledger_accept` after each submit. + auto-close ledgers, so the test calls `ledger_accept` after each submit. The + TypeScript XRPL integration test creates fresh accounts, submits native XRP + and issued-currency deposits, verifies each returned transaction reference, + and asserts the `ynet-account` memo carried the deposit destination. - **Solana** — the validator preloads the custody program **upgradeable** at its fixed id (`--upgradeable-program`), upgrade authority = the vendored `devnet/sol-upgrade-authority.json`. The test airdrop-funds the authority + @@ -60,6 +63,9 @@ npm --prefix sdk/ts run test:integration:evm make devnet-sol npm --prefix sdk/ts run test:integration:sol + +make devnet-xrpl +npm --prefix sdk/ts run test:integration:xrpl ``` ## Optional overrides @@ -71,6 +77,7 @@ Defaults target the devnet; override the endpoints if pointing elsewhere: | `EVM_RPC_URL` / `EVM_DEPLOYER_KEY` | `http://127.0.0.1:8545` / anvil account 0 | | `BTC_RPC_URL` / `BTC_RPC_USER` / `BTC_RPC_PASS` | `http://127.0.0.1:18443` / `sdk` / `sdk` | | `XRPL_RPC_URL` | `http://127.0.0.1:5005` | +| `XRPL_WS_URL` / `XRPL_ADMIN_RPC_URL` | `ws://127.0.0.1:6006` / `http://127.0.0.1:5005` | | `SOL_RPC_URL` | `http://127.0.0.1:8899` | ## Notes diff --git a/devnet/rippled.cfg b/devnet/rippled.cfg index 82bd09f..3256f6d 100644 --- a/devnet/rippled.cfg +++ b/devnet/rippled.cfg @@ -23,6 +23,9 @@ protocol = peer [node_size] tiny +[network_id] +21337 + [node_db] type=NuDB path=/var/lib/rippled/db/nudb diff --git a/sdk/ts/README.md b/sdk/ts/README.md index a0245df..ec4ae45 100644 --- a/sdk/ts/README.md +++ b/sdk/ts/README.md @@ -1,17 +1,19 @@ # Clearnet TypeScript SDK -TypeScript SDK for Clearnet integration. This package currently exposes EVM and -Solana vault depositors. EVM supports native ETH and ERC-20 deposits. Solana -supports native SOL and SPL token deposits. Deposits credit a `destination` made -of an account and an optional ADR-015 opaque reference. +TypeScript SDK for Clearnet integration. This package currently exposes EVM, +Solana, and XRPL 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. Deposits credit a `destination` made of an account and +an optional ADR-015 opaque reference. The package is ESM-first. EVM callers use `viem` clients and primitives. Solana -callers provide an SDK-owned signer adapter around their wallet or local keypair. +and XRPL callers provide SDK-owned signer adapters around their wallet or local +keypair. ## Install ```sh -npm install @yellow-org/clearnet-sdk viem @solana/web3.js +npm install @yellow-org/clearnet-sdk viem @solana/web3.js xrpl ``` For local development in this repository: @@ -178,6 +180,68 @@ deposits, pass the mint public key as `asset` and the amount in token base units The SDK 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`. Native XRP amounts are `bigint` drops. +Issued-currency amounts are positive decimal strings and assets use +`CUR.rIssuer` or `CUR:rIssuer`. + +```ts +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, +}); + +const ref = await depositor.submitDeposit({ + destination: { + account: "00000000000000000000000000000000000000a1", + ref: "0x3333333333333333333333333333333333333333333333333333333333333333", + }, + asset: XRPL_NATIVE_ASSET, + amount: 1_000_000n, +}); + +console.log(ref.raw); // uppercase XRPL transaction hash +console.log(ref.hash); // same bytes as 0x-prefixed hex +console.log(await depositor.verifyDeposit(ref, 0)); +``` + +For issued currencies, pass the asset key and decimal string amount: + +```ts +const ref = await depositor.submitDeposit({ + destination: { account: "00000000000000000000000000000000000000a1" }, + asset: "USD.rN7n7otQDd6FczFgLdSqtcsAUxDkw6fzRH", + amount: "25", +}); +``` + +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. Use +`verifyDeposit` to observe validated-ledger finality. + ## Deposit References Pass `destination.ref` to attach a 32-byte opaque sub-account reference to the @@ -195,8 +259,10 @@ const ref = await depositor.submitDeposit({ ``` 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]`. The SDK -does not interpret it. Omitted references are sent as 32 zero bytes. +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. ## Verify A Deposit @@ -215,7 +281,8 @@ const status = await depositor.verifyDeposit(ref, 1); `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`. +`finalized`. XRPL validates the shape for cross-chain parity but treats XRPL +finality as binary: a validated transaction is `confirmed`. ## API Reference @@ -294,6 +361,33 @@ Solana input fields: For Solana, `TxRef.raw` is the base58 signature and `TxRef.hash` is `0x` plus the SHA-256 digest of the signature bytes. +### `XrplVaultDepositor` + +```ts +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. | + +XRPL 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` | `XRP`/empty for native, or issued-currency `CUR.rIssuer` / `CUR:rIssuer`. | +| `amount` | `bigint \| string` | Native drops as `bigint`, issued-currency decimal value as `string`. | + +For XRPL, `TxRef.raw` is the uppercase 64-hex transaction hash and `TxRef.hash` +is the same bytes as `0x` hex. + ### `verifyDeposit(ref, minConfirmations)` Returns `Promise<"absent" | "pending" | "confirmed">`. @@ -308,6 +402,7 @@ 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 ``` Run the EVM integration test against local Anvil: @@ -344,7 +439,26 @@ The Solana devnet preloads the custody program at and funds local signers, creates SPL token accounts needed for the test, submits native SOL and SPL deposits, and verifies each returned transaction reference. -To run the repository integration suite, including the TS EVM and Solana +Run the XRPL integration test against local rippled: + +```sh +# From the repository root: +make devnet-xrpl + +# From sdk/ts: +npm run test:integration:xrpl + +# From the repository root: +make devnet-down +``` + +The 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, and XRPL integration tests: ```sh @@ -361,6 +475,7 @@ Start the browser demo from `sdk/ts`: ```sh npm run demo:evm npm run demo:sol +npm run demo:xrpl ``` The EVM demo expects: @@ -380,17 +495,22 @@ 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. +The XRPL demo uses GemWallet's browser API, asks the wallet to sign the prepared +payment, and submits the signed blob through the configured XRPL WebSocket URL. +The wallet must be funded, and issued-currency deposits require existing +trustlines and issued balances. + ## Troubleshooting Errors thrown by the SDK use `ClearnetSdkError` with a stable `code`. | Code | Common cause | |---|---| -| `INVALID_ADDRESS` | EVM address, Solana public key, Solana mint, program ID, or Clearnet account is invalid. | -| `INVALID_AMOUNT` | `amount` is not a positive `bigint` or exceeds the chain limit (`uint256` for EVM, `uint64` for Solana). | +| `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, or exceeds the chain limit (`uint256` for EVM, `uint64` for Solana/XRPL native drops). | | `INVALID_CONFIRMATIONS` | `minConfirmations` is negative, fractional, or an unsafe number. | | `INVALID_REFERENCE` | `destination.ref` is not a 32-byte hex value. | -| `INVALID_TX_REF` | `ref.hash` is not bytes32, or Solana `ref.raw` is not a 64-byte signature. | +| `INVALID_TX_REF` | `ref.hash` is not bytes32, Solana `ref.raw` is not a 64-byte signature, or XRPL `ref.raw` is not a 64-hex hash. | | `MISSING_WALLET_ACCOUNT` | The EVM wallet account is missing/mismatched, or the Solana signer is missing. | | `CHAIN_MISMATCH` | EVM only: the public RPC or wallet chain does not match `chainId`. | | `TX_REVERTED` | A submitted approval or deposit transaction reverted. | diff --git a/sdk/ts/examples/xrpl-deposit/index.html b/sdk/ts/examples/xrpl-deposit/index.html new file mode 100644 index 0000000..7d2e9e8 --- /dev/null +++ b/sdk/ts/examples/xrpl-deposit/index.html @@ -0,0 +1,173 @@ + + + + + + XRPL Deposit Demo + + + +
+

XRPL Deposit Demo

+
+
+ Network + + + +
+ +
+ Deposit + + + + +
+ +
+ + + +
+ +
+
+ + + diff --git a/sdk/ts/examples/xrpl-deposit/package.json b/sdk/ts/examples/xrpl-deposit/package.json new file mode 100644 index 0000000..c83679d --- /dev/null +++ b/sdk/ts/examples/xrpl-deposit/package.json @@ -0,0 +1,19 @@ +{ + "name": "@yellow-org/xrpl-deposit-demo", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite --host 127.0.0.1", + "build": "vite build" + }, + "dependencies": { + "@gemwallet/api": "^3.8.0", + "@yellow-org/clearnet-sdk": "file:../..", + "xrpl": "^5.0.0" + }, + "devDependencies": { + "typescript": "^5.9.0", + "vite": "^7.0.0" + } +} diff --git a/sdk/ts/examples/xrpl-deposit/src/main.ts b/sdk/ts/examples/xrpl-deposit/src/main.ts new file mode 100644 index 0000000..2012e4d --- /dev/null +++ b/sdk/ts/examples/xrpl-deposit/src/main.ts @@ -0,0 +1,195 @@ +import { + getAddress, + isInstalled, + signTransaction, +} from "@gemwallet/api"; +import { + XRPL_NATIVE_ASSET, + XrplVaultDepositor, +} from "@yellow-org/clearnet-sdk"; +import type { + Bytes32Hex, + TxRef, + XrplPreparedPayment, + XrplSigner, +} from "@yellow-org/clearnet-sdk"; +import { hashes, type SubmittableTransaction } from "xrpl"; + +const form = mustElement("deposit-form"); +const connectButton = mustElement("connect"); +const submitButton = mustElement("submit"); +const verifyButton = mustElement("verify"); +const logOutput = mustElement("log"); + +let signer: GemWalletSigner | undefined; +let lastRef: TxRef | undefined; + +connectButton.addEventListener("click", () => { + void connectWallet(); +}); + +form.addEventListener("submit", (event) => { + event.preventDefault(); + void submitDeposit(); +}); + +verifyButton.addEventListener("click", () => { + void verifyLastTx(); +}); + +writeLog("Connect GemWallet to submit an XRPL deposit."); + +async function connectWallet(): Promise { + setBusy(connectButton, true); + try { + const installed = await isInstalled(); + if (installed.result.isInstalled !== true) { + throw new Error("GemWallet extension is not installed"); + } + const response = await getAddress(); + const address = response.result?.address; + if (address === undefined || address === "") { + throw new Error("GemWallet did not return an address"); + } + signer = new GemWalletSigner(address); + writeLog(`Connected GemWallet ${address}`); + } catch (error) { + writeError(error); + } finally { + setBusy(connectButton, false); + } +} + +async function submitDeposit(): Promise { + if (signer === undefined) { + await connectWallet(); + } + if (signer === undefined) { + return; + } + + const ref = readOptional("reference"); + const maxFeeDrops = readOptional("max-fee-drops"); + const depositor = new XrplVaultDepositor({ + rpcUrl: readInput("rpc-url"), + vaultAddress: readInput("vault-address"), + signer, + ...(maxFeeDrops === undefined ? {} : { maxFeeDrops: BigInt(maxFeeDrops) }), + }); + + setBusy(submitButton, true); + try { + lastRef = await depositor.submitDeposit( + { + destination: { + account: readInput("account"), + ...(ref === undefined ? {} : { ref: ref as Bytes32Hex }), + }, + asset: readInput("asset"), + amount: readAmount(), + }, + { + onSubmitted(ref) { + lastRef = ref; + verifyButton.disabled = false; + writeLog(`Submitted ${ref.raw}\nhash: ${ref.hash}`); + }, + }, + ); + verifyButton.disabled = false; + writeLog(`Accepted ${lastRef.raw}\nhash: ${lastRef.hash}`); + } catch (error) { + const txRef = errorTxRef(error); + writeError(error, txRef === undefined ? undefined : `Submitted ${txRef.raw}`); + } finally { + setBusy(submitButton, false); + } +} + +async function verifyLastTx(): Promise { + if (lastRef === undefined || signer === undefined) { + return; + } + const maxFeeDrops = readOptional("max-fee-drops"); + const depositor = new XrplVaultDepositor({ + rpcUrl: readInput("rpc-url"), + vaultAddress: readInput("vault-address"), + signer, + ...(maxFeeDrops === undefined ? {} : { maxFeeDrops: BigInt(maxFeeDrops) }), + }); + + setBusy(verifyButton, true); + try { + const status = await depositor.verifyDeposit(lastRef, 0); + writeLog(`Verify ${lastRef.raw}\nstatus: ${status}`); + } catch (error) { + writeError(error); + } finally { + setBusy(verifyButton, false); + } +} + +class GemWalletSigner implements XrplSigner { + constructor(readonly classicAddress: string) {} + + async sign(payment: XrplPreparedPayment): Promise<{ txBlob: string; hash: string }> { + const response = await signTransaction({ + transaction: payment as SubmittableTransaction, + }); + const txBlob = response.result?.signature; + if (txBlob == null || txBlob === "") { + throw new Error("GemWallet did not return a signed transaction"); + } + return { + txBlob, + hash: hashes.hashSignedTx(txBlob), + }; + } +} + +function readAmount(): bigint | string { + const asset = readInput("asset"); + const amount = readInput("amount"); + if (asset === "" || asset.toUpperCase() === XRPL_NATIVE_ASSET) { + return BigInt(amount); + } + return amount; +} + +function readInput(id: string): string { + return mustElement(id).value.trim(); +} + +function readOptional(id: string): string | undefined { + const value = readInput(id); + return value === "" ? undefined : value; +} + +function mustElement(id: string): T { + const element = document.getElementById(id); + if (element === null) { + throw new Error(`Missing element #${id}`); + } + return element as T; +} + +function setBusy(button: HTMLButtonElement, busy: boolean): void { + button.disabled = busy; +} + +function writeLog(message: string): void { + logOutput.value = message; +} + +function writeError(error: unknown, prefix?: string): void { + const message = + error instanceof Error ? error.message : JSON.stringify(error, null, 2); + writeLog(prefix === undefined ? message : `${prefix}\n${message}`); +} + +function errorTxRef(error: unknown): TxRef | undefined { + if (error && typeof error === "object" && "txRef" in error) { + return (error as { txRef?: TxRef }).txRef; + } + return undefined; +} diff --git a/sdk/ts/examples/xrpl-deposit/tsconfig.json b/sdk/ts/examples/xrpl-deposit/tsconfig.json new file mode 100644 index 0000000..b85f514 --- /dev/null +++ b/sdk/ts/examples/xrpl-deposit/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "noEmit": true + }, + "include": ["src/**/*.ts"] +} diff --git a/sdk/ts/package-lock.json b/sdk/ts/package-lock.json index 3d3c90c..71a046a 100644 --- a/sdk/ts/package-lock.json +++ b/sdk/ts/package-lock.json @@ -16,7 +16,8 @@ "@solana/web3.js": "^1.98.4", "bs58": "^6.0.0", "buffer": "^6.0.3", - "viem": "^2.39.0" + "viem": "^2.39.0", + "xrpl": "^5.0.0" }, "devDependencies": { "@types/node": "^24.0.0", @@ -53,6 +54,19 @@ "vite": "^7.0.0" } }, + "examples/xrpl-deposit": { + "name": "@yellow-org/xrpl-deposit-demo", + "version": "0.0.0", + "dependencies": { + "@gemwallet/api": "^3.8.0", + "@yellow-org/clearnet-sdk": "file:../..", + "xrpl": "^5.0.0" + }, + "devDependencies": { + "typescript": "^5.9.0", + "vite": "^7.0.0" + } + }, "node_modules/@adraffy/ens-normalize": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.11.1.tgz", @@ -510,6 +524,11 @@ "node": ">=18" } }, + "node_modules/@gemwallet/api": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@gemwallet/api/-/api-3.8.0.tgz", + "integrity": "sha512-hZ6XC0mVm3Q54cgonrzk6tHS/wUMjtPHyqsqbtlnNGPouCR7OIfEDo5Y802qLZ5ah6PskhsK0DouVnwUykEM8Q==" + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -1365,6 +1384,30 @@ "node": ">=22" } }, + "node_modules/@xrplf/isomorphic": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@xrplf/isomorphic/-/isomorphic-1.0.2.tgz", + "integrity": "sha512-ncZUdMXr6VlSXtdoiDi0jTH+gBrgGxwVeEidhoegII3PmyErbQsyj6e+j7acmR4LW/lvBkPkzb9QzRfJH0n3rA==", + "license": "ISC", + "dependencies": { + "@noble/hashes": "^2.0.1", + "eventemitter3": "5.0.1", + "ws": "^8.20.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@xrplf/secret-numbers": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@xrplf/secret-numbers/-/secret-numbers-3.0.0.tgz", + "integrity": "sha512-qpGhAZXv5noMDjCtfzq5NK0y5rrdwTVjKhhPcAYSE+a/gogBOgqdpCKyieprVVPCnmVmJnGeRoZKBAqpCGegsA==", + "license": "ISC", + "dependencies": { + "@xrplf/isomorphic": "^1.0.2", + "ripple-keypairs": "^3.0.0" + } + }, "node_modules/@yellow-org/clearnet-sdk": { "resolved": "", "link": true @@ -1377,6 +1420,10 @@ "resolved": "examples/solana-deposit", "link": true }, + "node_modules/@yellow-org/xrpl-deposit-demo": { + "resolved": "examples/xrpl-deposit", + "link": true + }, "node_modules/abitype": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.2.3.tgz", @@ -1446,6 +1493,12 @@ ], "license": "MIT" }, + "node_modules/bignumber.js": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-10.0.2.tgz", + "integrity": "sha512-E8Wp9O06QA6lneJ4aRUXKYf/1GIomqUEmUMwtIOMtDxf1U52ffJY+y7JBk/8wRafA8qOIqLnXQGqonYXZdBnFQ==", + "license": "MIT" + }, "node_modules/bn.js": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.3.tgz", @@ -1676,6 +1729,12 @@ "node": "> 0.1.90" } }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "license": "MIT" + }, "node_modules/fast-stable-stringify": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fast-stable-stringify/-/fast-stable-stringify-1.0.0.tgz", @@ -1991,6 +2050,71 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/ripple-address-codec": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ripple-address-codec/-/ripple-address-codec-5.0.1.tgz", + "integrity": "sha512-JQHLKuVJV8lv9Qobmn4aUM2Dpv9WRRLKnNWfM8tN02fAbUtG8mUPsu9q9UYX8P76G4qzytEc5ZKMp/3JggNYmw==", + "license": "ISC", + "dependencies": { + "@scure/base": "^2.0.0", + "@xrplf/isomorphic": "^1.0.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/ripple-address-codec/node_modules/@scure/base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz", + "integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ripple-binary-codec": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/ripple-binary-codec/-/ripple-binary-codec-2.8.0.tgz", + "integrity": "sha512-+NKnOi3hdzjm5dDpoZLUEaYon1jahPlSGnp3YrDoNMSR09ICEqgupN5wpEkPuqJvV75PF/g+W1QUwIXVzbEe7w==", + "license": "ISC", + "dependencies": { + "@xrplf/isomorphic": "^1.0.2", + "bignumber.js": "^10.0.2", + "ripple-address-codec": "^5.0.1" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/ripple-keypairs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ripple-keypairs/-/ripple-keypairs-3.0.0.tgz", + "integrity": "sha512-lE69pD0E8hFNCqZoVXRyY45Yi8Ku+Qw7Rf1qRwPj4nOi34vp9NAuwzfiJH1IwXGWNCfEkwVfctG99CPTEoUf+g==", + "license": "ISC", + "dependencies": { + "@noble/curves": "^2.0.1", + "@xrplf/isomorphic": "^1.0.2", + "ripple-address-codec": "^5.0.1" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/ripple-keypairs/node_modules/@noble/curves": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.2.0.tgz", + "integrity": "sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.2.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/rollup": { "version": "4.62.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.0.tgz", @@ -2523,6 +2647,78 @@ "optional": true } } + }, + "node_modules/xrpl": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xrpl/-/xrpl-5.0.0.tgz", + "integrity": "sha512-YqaTFJUnhOu0mI4bsuHbKGj6w9ATcH8EIgw+gOLnh1rrlLTo5oImLQzhKJixCAPqqWOKnsY7J3jsN+l+zeEWgA==", + "license": "ISC", + "dependencies": { + "@scure/bip32": "^2.0.1", + "@scure/bip39": "^2.0.1", + "@xrplf/isomorphic": "^1.0.2", + "@xrplf/secret-numbers": "^3.0.0", + "bignumber.js": "^10.0.2", + "eventemitter3": "^5.0.1", + "fast-json-stable-stringify": "^2.1.0", + "ripple-address-codec": "^5.0.1", + "ripple-binary-codec": "^2.8.0", + "ripple-keypairs": "^3.0.0" + }, + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/xrpl/node_modules/@noble/curves": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.2.0.tgz", + "integrity": "sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.2.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/xrpl/node_modules/@scure/base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz", + "integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/xrpl/node_modules/@scure/bip32": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.2.0.tgz", + "integrity": "sha512-zFr7t2F+a9+5tB7QbarF2HQNYrgjCNaoLAupZdKkrFMYMozJf5zqH2WJCQibMzm1qQ0QogrxVGO3qXfQDYMaQg==", + "license": "MIT", + "dependencies": { + "@noble/curves": "2.2.0", + "@noble/hashes": "2.2.0", + "@scure/base": "2.2.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/xrpl/node_modules/@scure/bip39": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.2.0.tgz", + "integrity": "sha512-T/Bj/YvYMNkIPq6EENO6/rcs2e7qTNuyoUXf0KBFDmp0ZDu0H2X4Lq6yC3i0c8PcWkov5EbW+yQZZbdMmk154A==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.2.0", + "@scure/base": "2.2.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } } } } diff --git a/sdk/ts/package.json b/sdk/ts/package.json index 35e1b2f..e42d9d7 100644 --- a/sdk/ts/package.json +++ b/sdk/ts/package.json @@ -25,20 +25,24 @@ "build": "tsc -p tsconfig.json", "prepack": "npm run build", "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json --noEmit", - "test": "npm run test:evm && npm run test:sol", + "test": "npm run test:evm && npm run test:sol && npm run test:xrpl", "test:evm": "vitest run test/blockchain/evm/depositor.test.ts", "test:sol": "vitest run test/blockchain/sol/depositor.test.ts", + "test:xrpl": "vitest run test/blockchain/xrpl/depositor.test.ts", "test:integration:evm": "vitest run test/blockchain/evm/depositor.integration.test.ts", "test:integration:sol": "vitest run test/blockchain/sol/depositor.integration.test.ts", + "test:integration:xrpl": "vitest run test/blockchain/xrpl/depositor.integration.test.ts", "demo:evm": "npm --workspace @yellow-org/evm-deposit-demo run dev", - "demo:sol": "npm --workspace @yellow-org/solana-deposit-demo run dev" + "demo:sol": "npm --workspace @yellow-org/solana-deposit-demo run dev", + "demo:xrpl": "npm --workspace @yellow-org/xrpl-deposit-demo run dev" }, "dependencies": { "@noble/hashes": "^2.2.0", "@solana/web3.js": "^1.98.4", "bs58": "^6.0.0", "buffer": "^6.0.3", - "viem": "^2.39.0" + "viem": "^2.39.0", + "xrpl": "^5.0.0" }, "overrides": { "esbuild": "^0.28.1", diff --git a/sdk/ts/src/blockchain/xrpl/constants.ts b/sdk/ts/src/blockchain/xrpl/constants.ts new file mode 100644 index 0000000..5474515 --- /dev/null +++ b/sdk/ts/src/blockchain/xrpl/constants.ts @@ -0,0 +1,3 @@ +export const XRPL_NATIVE_ASSET = "XRP"; +export const XRPL_MEMO_TYPE = "796e65742d6163636f756e74"; +export const UINT64_MAX = (1n << 64n) - 1n; diff --git a/sdk/ts/src/blockchain/xrpl/depositor.ts b/sdk/ts/src/blockchain/xrpl/depositor.ts new file mode 100644 index 0000000..eb455af --- /dev/null +++ b/sdk/ts/src/blockchain/xrpl/depositor.ts @@ -0,0 +1,181 @@ +import { Client } from "xrpl"; +import type { Payment } from "xrpl"; + +import { ClearnetSdkError } from "../../core/errors.js"; +import type { + DepositStatus, + SubmitDepositOptions, + TxRef, + VaultDepositor, +} from "../../core/types.js"; +import { encodeClearnetMemo } from "./encoding.js"; +import type { + XrplDepositorConfig, + XrplSigner, + XrplSubmitDepositInput, +} from "./types.js"; +import { + normalizeFeeDrops, + normalizeMinConfirmations, + normalizeTxHash, + requireClassicAddress, + requireClearnetAccount, + requireReference, + requireRpcUrl, + requireSigner, + requireTxRef, + resolveAmount, +} from "./validation.js"; + +export class XrplVaultDepositor + implements VaultDepositor +{ + private readonly signer: XrplSigner; + private readonly vaultAddress: string; + private readonly maxFeeDrops: bigint | undefined; + private readonly client: Client; + + constructor(config: XrplDepositorConfig) { + this.signer = requireSigner(config.signer); + this.vaultAddress = requireClassicAddress(config.vaultAddress, "vaultAddress"); + this.maxFeeDrops = + config.maxFeeDrops === undefined + ? undefined + : normalizeFeeDrops(config.maxFeeDrops); + this.client = new Client(requireRpcUrl(config.rpcUrl)); + } + + async submitDeposit( + input: XrplSubmitDepositInput, + options: SubmitDepositOptions = {}, + ): Promise { + const account = requireClearnetAccount(input.destination.account); + const reference = requireReference(input.destination.ref); + const amount = resolveAmount(input.asset, input.amount); + const payment: Payment = { + TransactionType: "Payment", + Account: this.signer.classicAddress, + Destination: this.vaultAddress, + Amount: amount.amount, + Memos: encodeClearnetMemo(account, reference), + }; + + const prepared = await this.autofill(payment); + this.enforceFee(prepared); + const signed = await this.sign(prepared); + const ref = normalizeTxHash(signed.hash); + await this.submit(signed.txBlob, ref); + options.onSubmitted?.(ref); + return ref; + } + + async verifyDeposit( + ref: TxRef, + minConfirmations: bigint | number, + ): Promise { + const normalized = requireTxRef(ref); + normalizeMinConfirmations(minConfirmations); + await this.ensureConnected(); + try { + const response = await this.client.request({ + command: "tx", + transaction: normalized.raw, + }); + return response.result.validated === true ? "confirmed" : "pending"; + } catch (error) { + if (isTxnNotFound(error)) { + return "absent"; + } + throw new ClearnetSdkError("RPC_ERROR", "xrpl: tx lookup", { + cause: error, + }); + } + } + + private async autofill(payment: Payment): Promise { + await this.ensureConnected(); + try { + return await this.client.autofill(payment); + } catch (error) { + throw new ClearnetSdkError("RPC_ERROR", "xrpl: autofill", { + cause: error, + }); + } + } + + private enforceFee(prepared: Payment): void { + if (this.maxFeeDrops === undefined) { + return; + } + if (typeof prepared.Fee !== "string" || !/^[0-9]+$/.test(prepared.Fee)) { + throw new ClearnetSdkError( + "RPC_ERROR", + "xrpl: autofilled fee is missing or invalid", + ); + } + if (BigInt(prepared.Fee) > this.maxFeeDrops) { + throw new ClearnetSdkError( + "INVALID_AMOUNT", + "xrpl: autofilled fee exceeds maxFeeDrops", + ); + } + } + + private async sign(prepared: Payment): Promise<{ txBlob: string; hash: string }> { + try { + return await this.signer.sign(prepared); + } catch (error) { + if (error instanceof ClearnetSdkError) { + throw error; + } + throw new ClearnetSdkError("RPC_ERROR", "xrpl: sign", { + cause: error, + }); + } + } + + private async submit(txBlob: string, ref: TxRef): Promise { + try { + const response = await this.client.submit(txBlob, { autofill: false }); + const engineResult = response.result.engine_result; + if (engineResult !== "tesSUCCESS" && engineResult !== "terQUEUED") { + throw new ClearnetSdkError( + "RPC_ERROR", + `xrpl: deposit rejected: ${engineResult}`, + { txRef: ref }, + ); + } + } catch (error) { + if (error instanceof ClearnetSdkError) { + throw error; + } + throw new ClearnetSdkError("RPC_ERROR", "xrpl: submit", { + txRef: ref, + cause: error, + }); + } + } + + private async ensureConnected(): Promise { + if (this.client.isConnected()) { + return; + } + try { + await this.client.connect(); + } catch (error) { + throw new ClearnetSdkError("RPC_ERROR", "xrpl: connect", { + cause: error, + }); + } + } +} + +function isTxnNotFound(error: unknown): boolean { + if (!error || typeof error !== "object") { + return false; + } + const message = + "message" in error && typeof error.message === "string" ? error.message : ""; + const data = "data" in error ? String(error.data) : ""; + return message.includes("txnNotFound") || data.includes("txnNotFound"); +} diff --git a/sdk/ts/src/blockchain/xrpl/encoding.ts b/sdk/ts/src/blockchain/xrpl/encoding.ts new file mode 100644 index 0000000..9701368 --- /dev/null +++ b/sdk/ts/src/blockchain/xrpl/encoding.ts @@ -0,0 +1,23 @@ +import { Buffer } from "buffer"; + +import type { Memo } from "xrpl"; + +import { XRPL_MEMO_TYPE } from "./constants.js"; + +export function encodeClearnetMemo( + account: Uint8Array, + reference: Uint8Array, +): Memo[] { + return [ + { + Memo: { + MemoType: XRPL_MEMO_TYPE, + MemoData: hex(account) + hex(reference), + }, + }, + ]; +} + +export function hex(bytes: Uint8Array): string { + return Buffer.from(bytes).toString("hex"); +} diff --git a/sdk/ts/src/blockchain/xrpl/index.ts b/sdk/ts/src/blockchain/xrpl/index.ts new file mode 100644 index 0000000..c6d0459 --- /dev/null +++ b/sdk/ts/src/blockchain/xrpl/index.ts @@ -0,0 +1,14 @@ +export { XRPL_NATIVE_ASSET } from "./constants.js"; +export { XrplVaultDepositor } from "./depositor.js"; +export type { + XrplAmount, + XrplAsset, + XrplDepositDestination, + XrplDepositorConfig, + XrplIssuedDepositInput, + XrplNativeDepositInput, + XrplPreparedPayment, + XrplSignedTransaction, + XrplSigner, + XrplSubmitDepositInput, +} from "./types.js"; diff --git a/sdk/ts/src/blockchain/xrpl/types.ts b/sdk/ts/src/blockchain/xrpl/types.ts new file mode 100644 index 0000000..768f7b0 --- /dev/null +++ b/sdk/ts/src/blockchain/xrpl/types.ts @@ -0,0 +1,52 @@ +import type { Payment } from "xrpl"; + +import type { + Bytes32Hex, + DepositDestination, + SubmitDepositInput, +} from "../../core/types.js"; + +export type XrplAmount = bigint | string; +export type XrplAsset = string; +export type XrplPreparedPayment = Payment; + +export interface XrplDepositDestination extends DepositDestination { + account: string; + ref?: Bytes32Hex; +} + +export interface XrplSubmitDepositInput + extends SubmitDepositInput { + asset: XrplAsset; + amount: XrplAmount; + destination: XrplDepositDestination; +} + +export interface XrplNativeDepositInput extends SubmitDepositInput { + asset: "" | "XRP"; + amount: bigint; + destination: XrplDepositDestination; +} + +export interface XrplIssuedDepositInput extends SubmitDepositInput { + asset: `${string}.${string}` | `${string}:${string}`; + amount: string; + destination: XrplDepositDestination; +} + +export interface XrplSignedTransaction { + txBlob: string; + hash: string; +} + +export interface XrplSigner { + readonly classicAddress: string; + sign(payment: XrplPreparedPayment): Promise; +} + +export interface XrplDepositorConfig { + rpcUrl: string; + vaultAddress: string; + signer: XrplSigner; + maxFeeDrops?: bigint | number; +} diff --git a/sdk/ts/src/blockchain/xrpl/validation.ts b/sdk/ts/src/blockchain/xrpl/validation.ts new file mode 100644 index 0000000..d00235f --- /dev/null +++ b/sdk/ts/src/blockchain/xrpl/validation.ts @@ -0,0 +1,274 @@ +import { Buffer } from "buffer"; + +import { isValidClassicAddress } from "xrpl"; + +import { ClearnetSdkError } from "../../core/errors.js"; +import type { Bytes32Hex, TxRef } from "../../core/types.js"; +import { UINT64_MAX, XRPL_NATIVE_ASSET } from "./constants.js"; +import type { XrplSigner } from "./types.js"; + +const BYTES32_HEX_PATTERN = /^0x[a-fA-F0-9]{64}$/; +const HASH_PATTERN = /^[a-fA-F0-9]{64}$/; +const DECIMAL_PATTERN = /^(?:0|[1-9][0-9]*)(?:\.[0-9]+)?$/; +const STANDARD_CURRENCY_PATTERN = /^[A-Za-z0-9]{3}$/; +const HEX_CURRENCY_PATTERN = /^[a-fA-F0-9]{40}$/; + +export type ResolvedXrplAmount = + | { kind: "native"; amount: string } + | { + kind: "issued"; + amount: { currency: string; issuer: string; value: string }; + }; + +export function requireRpcUrl(rpcUrl: unknown): string { + if (typeof rpcUrl !== "string" || rpcUrl.trim() === "") { + throw new ClearnetSdkError("RPC_ERROR", "rpcUrl is required"); + } + let url: URL; + try { + url = new URL(rpcUrl); + } catch (error) { + throw new ClearnetSdkError( + "RPC_ERROR", + "rpcUrl must be a valid XRPL WebSocket URL", + { cause: error }, + ); + } + if (url.protocol !== "ws:" && url.protocol !== "wss:") { + throw new ClearnetSdkError( + "RPC_ERROR", + "rpcUrl must use ws: or wss: for xrpl.js", + ); + } + return rpcUrl; +} + +export function requireClassicAddress(value: unknown, field: string): string { + if (typeof value !== "string" || !isValidClassicAddress(value)) { + throw new ClearnetSdkError( + "INVALID_ADDRESS", + `${field} must be a valid XRPL classic address`, + ); + } + return value; +} + +export function requireSigner(signer: unknown): XrplSigner { + if (!signer || typeof signer !== "object") { + throw new ClearnetSdkError( + "INVALID_ADDRESS", + "XRPL signer is required", + ); + } + const candidate = signer as Partial; + requireClassicAddress(candidate.classicAddress, "signer.classicAddress"); + if (typeof candidate.sign !== "function") { + throw new ClearnetSdkError( + "INVALID_ADDRESS", + "XRPL signer.sign is required", + ); + } + return candidate as XrplSigner; +} + +export function requireClearnetAccount(account: unknown): Uint8Array { + if (typeof account !== "string") { + throw new ClearnetSdkError( + "INVALID_ADDRESS", + "destination.account must be a 20-byte hex address", + ); + } + const trimmed = account.trim(); + const segment = trimmed.slice(trimmed.lastIndexOf("/") + 1); + const hex = segment.toLowerCase().replace(/^0x/, ""); + if (!/^[a-f0-9]+$/.test(hex) || hex.length !== 40) { + throw new ClearnetSdkError( + "INVALID_ADDRESS", + "destination.account must be a 20-byte hex address", + ); + } + return Uint8Array.from(Buffer.from(hex, "hex")); +} + +export function requireReference(reference: unknown): Uint8Array { + if (reference === undefined || reference === "") { + return new Uint8Array(32); + } + if (typeof reference !== "string" || !BYTES32_HEX_PATTERN.test(reference)) { + throw new ClearnetSdkError( + "INVALID_REFERENCE", + "destination.ref must be a 32-byte hex value", + ); + } + return Uint8Array.from(Buffer.from(reference.slice(2), "hex")); +} + +export function resolveAmount( + asset: unknown, + amount: unknown, +): ResolvedXrplAmount { + if (isNativeAsset(asset)) { + return { kind: "native", amount: requireNativeAmount(amount).toString() }; + } + const issued = requireIssuedAsset(asset); + const value = requireIssuedAmount(amount); + return { + kind: "issued", + amount: { currency: issued.currency, issuer: issued.issuer, value }, + }; +} + +export function normalizeFeeDrops(value: bigint | number): bigint { + if (typeof value === "bigint") { + if (value <= 0n || value > UINT64_MAX) { + throw new ClearnetSdkError( + "INVALID_AMOUNT", + "maxFeeDrops must be a positive uint64 value", + ); + } + return value; + } + if (!Number.isSafeInteger(value) || value <= 0) { + throw new ClearnetSdkError( + "INVALID_AMOUNT", + "maxFeeDrops must be a positive safe integer", + ); + } + return BigInt(value); +} + +export function normalizeTxHash(hash: string): TxRef { + if (!HASH_PATTERN.test(hash)) { + throw new ClearnetSdkError( + "INVALID_TX_REF", + "XRPL transaction hash must be 64 hex characters", + ); + } + const raw = hash.toUpperCase(); + return { hash: `0x${raw.toLowerCase()}` as Bytes32Hex, raw }; +} + +export function requireTxRef(ref: unknown): TxRef { + if (!ref || typeof ref !== "object") { + throw new ClearnetSdkError( + "INVALID_TX_REF", + "ref.raw must be an XRPL transaction hash", + ); + } + const fields = ref as Partial; + if (typeof fields.raw !== "string" || !HASH_PATTERN.test(fields.raw)) { + throw new ClearnetSdkError( + "INVALID_TX_REF", + "ref.raw must be an XRPL transaction hash", + ); + } + if (typeof fields.hash !== "string" || !BYTES32_HEX_PATTERN.test(fields.hash)) { + throw new ClearnetSdkError( + "INVALID_TX_REF", + "ref.hash must be a 32-byte hex value", + ); + } + const normalized = normalizeTxHash(fields.raw); + if (normalized.hash.toLowerCase() !== fields.hash.toLowerCase()) { + throw new ClearnetSdkError( + "INVALID_TX_REF", + "ref.hash must match ref.raw", + ); + } + return normalized; +} + +export function normalizeMinConfirmations(value: bigint | number): bigint { + if (typeof value === "bigint") { + if (value < 0n) { + throw new ClearnetSdkError( + "INVALID_CONFIRMATIONS", + "minConfirmations must be non-negative", + ); + } + return value; + } + if (!Number.isSafeInteger(value) || value < 0) { + throw new ClearnetSdkError( + "INVALID_CONFIRMATIONS", + "minConfirmations must be a non-negative safe integer", + ); + } + return BigInt(value); +} + +function isNativeAsset(asset: unknown): boolean { + if (asset === "" || asset === undefined) { + return true; + } + return typeof asset === "string" && asset.trim().toUpperCase() === XRPL_NATIVE_ASSET; +} + +function requireNativeAmount(amount: unknown): bigint { + if (typeof amount !== "bigint") { + throw new ClearnetSdkError( + "INVALID_AMOUNT", + "native XRP amount must be a bigint in drops", + ); + } + if (amount <= 0n || amount > UINT64_MAX) { + throw new ClearnetSdkError( + "INVALID_AMOUNT", + "native XRP amount must be a positive uint64 drops value", + ); + } + return amount; +} + +function requireIssuedAsset(asset: unknown): { currency: string; issuer: string } { + if (typeof asset !== "string") { + throw new ClearnetSdkError( + "INVALID_ADDRESS", + "issued XRPL asset must be CUR.rIssuer or CUR:rIssuer", + ); + } + const trimmed = asset.trim(); + const dot = trimmed.indexOf("."); + const colon = trimmed.indexOf(":"); + const separator = + dot > 0 && (colon < 0 || dot < colon) ? dot : colon > 0 ? colon : -1; + if (separator <= 0 || separator === trimmed.length - 1) { + throw new ClearnetSdkError( + "INVALID_ADDRESS", + "issued XRPL asset must be CUR.rIssuer or CUR:rIssuer", + ); + } + const currency = trimmed.slice(0, separator); + const issuer = trimmed.slice(separator + 1); + if ( + currency.toUpperCase() === XRPL_NATIVE_ASSET || + (!STANDARD_CURRENCY_PATTERN.test(currency) && + !HEX_CURRENCY_PATTERN.test(currency)) + ) { + throw new ClearnetSdkError( + "INVALID_ADDRESS", + "issued XRPL currency must be a 3-character code or 20-byte hex code", + ); + } + return { currency, issuer: requireClassicAddress(issuer, "asset issuer") }; +} + +function requireIssuedAmount(amount: unknown): string { + if (typeof amount !== "string") { + throw new ClearnetSdkError( + "INVALID_AMOUNT", + "issued XRPL amount must be a decimal string", + ); + } + const trimmed = amount.trim(); + if ( + !DECIMAL_PATTERN.test(trimmed) || + trimmed.replace(".", "").replace(/0/g, "") === "" + ) { + throw new ClearnetSdkError( + "INVALID_AMOUNT", + "issued XRPL amount must be a positive decimal string", + ); + } + return trimmed; +} diff --git a/sdk/ts/src/core/types.ts b/sdk/ts/src/core/types.ts index 7826058..911d034 100644 --- a/sdk/ts/src/core/types.ts +++ b/sdk/ts/src/core/types.ts @@ -23,9 +23,9 @@ export interface EvmDepositDestination extends DepositDestination { account: Address; } -export interface SubmitDepositInput { +export interface SubmitDepositInput { asset: string; - amount: bigint; + amount: TAmount; destination: DepositDestination; } @@ -41,7 +41,7 @@ export interface SubmitDepositOptions { } export interface VaultDepositor< - TInput extends SubmitDepositInput = SubmitDepositInput, + TInput extends SubmitDepositInput = SubmitDepositInput, > { submitDeposit(input: TInput, options?: SubmitDepositOptions): Promise; verifyDeposit( diff --git a/sdk/ts/src/index.ts b/sdk/ts/src/index.ts index 07042ce..733ddd3 100644 --- a/sdk/ts/src/index.ts +++ b/sdk/ts/src/index.ts @@ -27,3 +27,19 @@ export type { SolanaSigner, SolanaSubmitDepositInput, } from "./blockchain/sol/index.js"; +export { + XRPL_NATIVE_ASSET, + XrplVaultDepositor, +} from "./blockchain/xrpl/index.js"; +export type { + XrplAmount, + XrplAsset, + XrplDepositDestination, + XrplDepositorConfig, + XrplIssuedDepositInput, + XrplNativeDepositInput, + XrplPreparedPayment, + XrplSignedTransaction, + XrplSigner, + XrplSubmitDepositInput, +} from "./blockchain/xrpl/index.js"; diff --git a/sdk/ts/test/blockchain/xrpl/depositor.integration.test.ts b/sdk/ts/test/blockchain/xrpl/depositor.integration.test.ts new file mode 100644 index 0000000..8d41c84 --- /dev/null +++ b/sdk/ts/test/blockchain/xrpl/depositor.integration.test.ts @@ -0,0 +1,255 @@ +import type { Payment, SubmittableTransaction } from "xrpl"; +import { Client, Wallet } from "xrpl"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { + XRPL_NATIVE_ASSET, + XrplVaultDepositor, +} from "../../../src/index.js"; +import type { + Bytes32Hex, + TxRef, + XrplSigner, +} from "../../../src/index.js"; + +const XRPL_WS_URL = env("XRPL_WS_URL", "ws://127.0.0.1:6006"); +const XRPL_ADMIN_RPC_URL = env("XRPL_ADMIN_RPC_URL", "http://127.0.0.1:5005"); +const GENESIS_SEED = "snoPBrXtMeMyMHUVTgbuqAfg1SUTb"; +const ACCOUNT = "0x00000000000000000000000000000000000000a1"; +const REFERENCE = + "0x1111111111111111111111111111111111111111111111111111111111111111" as Bytes32Hex; +const MEMO_TYPE = "796E65742D6163636F756E74"; +const ASF_DEFAULT_RIPPLE = 8; + +type FetchedPayment = Omit & { + Amount?: Payment["Amount"]; + DeliverMax?: Payment["Amount"]; +}; + +describe("XrplVaultDepositor integration", () => { + let client: Client; + let admin: XrplAdmin; + let master: Wallet; + let vault: Wallet; + let depositorWallet: Wallet; + + beforeAll(async () => { + client = new Client(XRPL_WS_URL); + await client.connect(); + admin = new XrplAdmin(XRPL_ADMIN_RPC_URL); + master = Wallet.fromSeed(GENESIS_SEED); + vault = Wallet.generate(); + depositorWallet = Wallet.generate(); + await fund(client, admin, master, vault.classicAddress, "1000000000"); + await fund(client, admin, master, depositorWallet.classicAddress, "1000000000"); + }, 60_000); + + afterAll(async () => { + if (client?.isConnected()) { + await client.disconnect(); + } + }); + + it("submits and verifies a native XRP deposit", async () => { + const sdk = new XrplVaultDepositor({ + rpcUrl: XRPL_WS_URL, + vaultAddress: vault.classicAddress, + signer: signerFromWallet(depositorWallet), + }); + + const ref = await sdk.submitDeposit({ + asset: XRPL_NATIVE_ASSET, + amount: 10_000_000n, + destination: { account: ACCOUNT, ref: REFERENCE }, + }); + await admin.ledgerAccept(); + + await expect(sdk.verifyDeposit(ref, 0)).resolves.toBe("confirmed"); + const payment = await fetchPayment(client, ref); + expect(payment.Account).toBe(depositorWallet.classicAddress); + expect(payment.Destination).toBe(vault.classicAddress); + expect(paymentAmount(payment)).toBe("10000000"); + expect(payment.Memos).toEqual(expectedMemo(ACCOUNT, REFERENCE)); + }, 60_000); + + it("submits and verifies an issued-currency deposit", async () => { + const issuer = Wallet.generate(); + await fund(client, admin, master, issuer.classicAddress, "1000000000"); + await enableDefaultRipple(client, admin, issuer); + await trustSet(client, admin, depositorWallet, issuer.classicAddress, "USD", "1000"); + await trustSet(client, admin, vault, issuer.classicAddress, "USD", "1000"); + await issueCurrency( + client, + admin, + issuer, + depositorWallet.classicAddress, + "USD", + "100", + ); + + const sdk = new XrplVaultDepositor({ + rpcUrl: XRPL_WS_URL, + vaultAddress: vault.classicAddress, + signer: signerFromWallet(depositorWallet), + }); + const ref = await sdk.submitDeposit({ + asset: `USD.${issuer.classicAddress}`, + amount: "25", + destination: { account: ACCOUNT }, + }); + await admin.ledgerAccept(); + + await expect(sdk.verifyDeposit(ref, 0)).resolves.toBe("confirmed"); + const payment = await fetchPayment(client, ref); + expect(paymentAmount(payment)).toEqual({ + currency: "USD", + issuer: issuer.classicAddress, + value: "25", + }); + expect(payment.Memos).toEqual(expectedMemo(ACCOUNT)); + }, 90_000); +}); + +function signerFromWallet(wallet: Wallet): XrplSigner { + return { + classicAddress: wallet.classicAddress, + sign: async (payment) => { + const signed = wallet.sign(payment as SubmittableTransaction); + return { txBlob: signed.tx_blob, hash: signed.hash }; + }, + }; +} + +async function fund( + client: Client, + admin: XrplAdmin, + source: Wallet, + destination: string, + amountDrops: string, +): Promise { + await submitAndAccept(client, admin, source, { + TransactionType: "Payment", + Account: source.classicAddress, + Destination: destination, + Amount: amountDrops, + }); +} + +async function enableDefaultRipple( + client: Client, + admin: XrplAdmin, + issuer: Wallet, +): Promise { + await submitAndAccept(client, admin, issuer, { + TransactionType: "AccountSet", + Account: issuer.classicAddress, + SetFlag: ASF_DEFAULT_RIPPLE, + }); +} + +async function trustSet( + client: Client, + admin: XrplAdmin, + wallet: Wallet, + issuer: string, + currency: string, + value: string, +): Promise { + await submitAndAccept(client, admin, wallet, { + TransactionType: "TrustSet", + Account: wallet.classicAddress, + LimitAmount: { currency, issuer, value }, + }); +} + +async function issueCurrency( + client: Client, + admin: XrplAdmin, + issuer: Wallet, + destination: string, + currency: string, + value: string, +): Promise { + await submitAndAccept(client, admin, issuer, { + TransactionType: "Payment", + Account: issuer.classicAddress, + Destination: destination, + Amount: { currency, issuer: issuer.classicAddress, value }, + }); +} + +async function submitAndAccept( + client: Client, + admin: XrplAdmin, + wallet: Wallet, + tx: SubmittableTransaction, +): Promise { + const prepared = await client.autofill(tx); + const signed = wallet.sign(prepared); + const result = await client.submit(signed.tx_blob, { autofill: false }); + if ( + result.result.engine_result !== "tesSUCCESS" && + result.result.engine_result !== "terQUEUED" + ) { + throw new Error( + `XRPL setup tx rejected: ${result.result.engine_result} ${result.result.engine_result_message}`, + ); + } + await admin.ledgerAccept(); + return signed.hash; +} + +async function fetchPayment(client: Client, ref: TxRef): Promise { + const response = await client.request({ + command: "tx", + transaction: ref.raw, + }); + const result = response.result as unknown as { + tx_json?: FetchedPayment; + tx?: FetchedPayment; + }; + return (result.tx_json ?? result.tx) as FetchedPayment; +} + +function paymentAmount(payment: FetchedPayment): Payment["Amount"] | undefined { + return payment.Amount ?? payment.DeliverMax; +} + +function expectedMemo(account: string, ref?: Bytes32Hex): Payment["Memos"] { + return [ + { + Memo: { + MemoType: MEMO_TYPE, + MemoData: `${account.replace(/^0x/, "")}${(ref ?? zeroRef()).slice(2)}`.toUpperCase(), + }, + }, + ]; +} + +function zeroRef(): Bytes32Hex { + return `0x${"00".repeat(32)}`; +} + +class XrplAdmin { + constructor(private readonly rpcUrl: string) {} + + async ledgerAccept(): Promise { + const response = await fetch(this.rpcUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ method: "ledger_accept", params: [] }), + }); + if (!response.ok) { + throw new Error(`ledger_accept failed with HTTP ${response.status}`); + } + const body = (await response.json()) as { result?: { status?: string } }; + if (body.result?.status !== "success") { + throw new Error(`ledger_accept failed: ${JSON.stringify(body)}`); + } + } +} + +function env(key: string, fallback: string): string { + const value = process.env[key]; + return value === undefined || value === "" ? fallback : value; +} diff --git a/sdk/ts/test/blockchain/xrpl/depositor.test.ts b/sdk/ts/test/blockchain/xrpl/depositor.test.ts new file mode 100644 index 0000000..4bdd2dd --- /dev/null +++ b/sdk/ts/test/blockchain/xrpl/depositor.test.ts @@ -0,0 +1,439 @@ +import type { Payment, SubmitResponse, TxResponse } from "xrpl"; +import { afterEach, beforeEach, describe, expect, expectTypeOf, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + clientConstructor: vi.fn(), +})); + +vi.mock("xrpl", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + Client: mocks.clientConstructor, + }; +}); + +import { + ClearnetSdkError, + XrplVaultDepositor, + XRPL_NATIVE_ASSET, +} from "../../../src/index.js"; +import type { + Bytes32Hex, + DepositStatus, + TxRef, + VaultDepositor, + XrplIssuedDepositInput, + XrplNativeDepositInput, + XrplSigner, + XrplSubmitDepositInput, +} from "../../../src/index.js"; + +const RPC_URL = "ws://127.0.0.1:6006"; +const VAULT_ADDRESS = "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh"; +const DEPOSITOR_ADDRESS = "rPT1Sjq2YGrBMTttX4GZHjKu9dyfzbpAYe"; +const ISSUER_ADDRESS = "rN7n7otQDd6FczFgLdSqtcsAUxDkw6fzRH"; +const ACCOUNT = "0x1111111111111111111111111111111111111111"; +const ACCOUNT_NO_PREFIX = ACCOUNT.slice(2); +const REFERENCE = + "0x2222222222222222222222222222222222222222222222222222222222222222" as Bytes32Hex; +const HASH_RAW = + "ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789"; +const HASH_REF = { + hash: `0x${HASH_RAW.toLowerCase()}`, + raw: HASH_RAW, +} satisfies TxRef; +const TX_BLOB = "1200002280000000240000000161400000000000000A68400000000000000C"; +const MEMO_TYPE = "796e65742d6163636f756e74"; + +interface MockXrplClient { + connect: ReturnType Promise>>; + disconnect: ReturnType Promise>>; + isConnected: ReturnType boolean>>; + autofill: ReturnType Promise>>; + submit: ReturnType Promise>>; + request: ReturnType Promise>>; +} + +interface MockSigner extends XrplSigner { + sign: ReturnType Promise<{ txBlob: string; hash: string }>>>; +} + +describe("XrplVaultDepositor", () => { + let client: MockXrplClient; + + beforeEach(() => { + client = createClient(); + mocks.clientConstructor.mockReset(); + mocks.clientConstructor.mockImplementation(function Client() { + return client; + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("matches the public depositor and result type contracts", () => { + expectTypeOf().toMatchTypeOf< + VaultDepositor + >(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf<{ hash: Bytes32Hex; raw: string }>(); + expectTypeOf().toEqualTypeOf< + "absent" | "pending" | "confirmed" + >(); + expect(XRPL_NATIVE_ASSET).toBe("XRP"); + }); + + it("submits native XRP drops with the ynet-account memo and Go-compatible tx ref", async () => { + const signer = createSigner(); + const depositor = createDepositor(signer); + const onSubmitted = vi.fn(); + + const ref = await depositor.submitDeposit( + { + asset: XRPL_NATIVE_ASSET, + amount: 10n, + destination: { account: ` ${ACCOUNT} `, ref: REFERENCE }, + }, + { onSubmitted }, + ); + + expect(mocks.clientConstructor).toHaveBeenCalledExactlyOnceWith(RPC_URL); + expect(client.connect).toHaveBeenCalledOnce(); + expect(client.autofill).toHaveBeenCalledExactlyOnceWith({ + TransactionType: "Payment", + Account: DEPOSITOR_ADDRESS, + Destination: VAULT_ADDRESS, + Amount: "10", + Memos: [ + { + Memo: { + MemoType: MEMO_TYPE, + MemoData: `${ACCOUNT_NO_PREFIX}${REFERENCE.slice(2)}`, + }, + }, + ], + }); + expect(signer.sign).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ Fee: "12", Sequence: 1 }), + ); + expect(client.submit).toHaveBeenCalledExactlyOnceWith(TX_BLOB, { + autofill: false, + }); + expect(ref).toEqual(HASH_REF); + expect(onSubmitted).toHaveBeenCalledExactlyOnceWith(ref); + }); + + it("submits issued-currency deposits using both supported asset delimiters", async () => { + const signer = createSigner(); + const depositor = createDepositor(signer); + + await depositor.submitDeposit({ + asset: `USD.${ISSUER_ADDRESS}`, + amount: "12.345", + destination: { account: ACCOUNT }, + }); + await depositor.submitDeposit({ + asset: `EUR:${ISSUER_ADDRESS}`, + amount: "1", + destination: { account: ACCOUNT }, + }); + + expect(client.autofill).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + Amount: { + currency: "USD", + issuer: ISSUER_ADDRESS, + value: "12.345", + }, + }), + ); + expect(client.autofill).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + Amount: { + currency: "EUR", + issuer: ISSUER_ADDRESS, + value: "1", + }, + }), + ); + }); + + it("accepts terQUEUED submit results and returns without waiting for validation", async () => { + client.submit.mockResolvedValueOnce(submitResponse("terQUEUED")); + const signer = createSigner(); + const depositor = createDepositor(signer); + + await expect( + depositor.submitDeposit({ + asset: XRPL_NATIVE_ASSET, + amount: 1n, + destination: { account: ACCOUNT }, + }), + ).resolves.toEqual(HASH_REF); + + expect(client.request).not.toHaveBeenCalled(); + }); + + it("rejects invalid inputs before autofill, signing, or submission", async () => { + const signer = createSigner(); + const depositor = createDepositor(signer); + + await expect( + depositor.submitDeposit({ + asset: XRPL_NATIVE_ASSET, + amount: 0n, + destination: { account: ACCOUNT }, + }), + ).rejects.toMatchObject({ code: "INVALID_AMOUNT" }); + await expect( + depositor.submitDeposit({ + asset: XRPL_NATIVE_ASSET, + amount: 1.5 as unknown as bigint, + destination: { account: ACCOUNT }, + }), + ).rejects.toMatchObject({ code: "INVALID_AMOUNT" }); + await expect( + depositor.submitDeposit({ + asset: `USD.${ISSUER_ADDRESS}`, + amount: "1e2", + destination: { account: ACCOUNT }, + }), + ).rejects.toMatchObject({ code: "INVALID_AMOUNT" }); + await expect( + depositor.submitDeposit({ + asset: `USD.${ISSUER_ADDRESS}`, + amount: "0", + destination: { account: ACCOUNT }, + }), + ).rejects.toMatchObject({ code: "INVALID_AMOUNT" }); + await expect( + depositor.submitDeposit({ + asset: "USD", + amount: "1", + destination: { account: ACCOUNT }, + }), + ).rejects.toMatchObject({ code: "INVALID_ADDRESS" }); + await expect( + depositor.submitDeposit({ + asset: "USD.rBad", + amount: "1", + destination: { account: ACCOUNT }, + }), + ).rejects.toMatchObject({ code: "INVALID_ADDRESS" }); + await expect( + depositor.submitDeposit({ + asset: XRPL_NATIVE_ASSET, + amount: 1n, + destination: { account: "0x1234" }, + }), + ).rejects.toMatchObject({ code: "INVALID_ADDRESS" }); + await expect( + depositor.submitDeposit({ + asset: XRPL_NATIVE_ASSET, + amount: 1n, + destination: { account: ACCOUNT, ref: "memo-1" as Bytes32Hex }, + }), + ).rejects.toMatchObject({ code: "INVALID_REFERENCE" }); + + expect(client.autofill).not.toHaveBeenCalled(); + expect(signer.sign).not.toHaveBeenCalled(); + expect(client.submit).not.toHaveBeenCalled(); + }); + + it("rejects invalid constructor inputs with XRPL validation errors", () => { + expect(() => + new XrplVaultDepositor({ + rpcUrl: RPC_URL, + vaultAddress: VAULT_ADDRESS, + signer: undefined as unknown as XrplSigner, + }), + ).toThrowError( + expect.objectContaining({ code: "INVALID_ADDRESS" }), + ); + expect(() => + new XrplVaultDepositor({ + rpcUrl: RPC_URL, + vaultAddress: VAULT_ADDRESS, + signer: { classicAddress: "rBad" } as XrplSigner, + }), + ).toThrowError( + expect.objectContaining({ code: "INVALID_ADDRESS" }), + ); + expect(() => + new XrplVaultDepositor({ + rpcUrl: RPC_URL, + vaultAddress: VAULT_ADDRESS, + signer: { classicAddress: DEPOSITOR_ADDRESS } as XrplSigner, + }), + ).toThrowError( + expect.objectContaining({ code: "INVALID_ADDRESS" }), + ); + expect(() => + new XrplVaultDepositor({ + rpcUrl: "http://127.0.0.1:5005", + vaultAddress: VAULT_ADDRESS, + signer: createSigner(), + }), + ).toThrowError( + expect.objectContaining({ code: "RPC_ERROR" }), + ); + }); + + it("enforces maxFeeDrops after autofill and before signing", async () => { + client.autofill.mockResolvedValueOnce(preparedPayment({ Fee: "13" })); + const signer = createSigner(); + const depositor = createDepositor(signer, { maxFeeDrops: 12n }); + + await expect( + depositor.submitDeposit({ + asset: XRPL_NATIVE_ASSET, + amount: 1n, + destination: { account: ACCOUNT }, + }), + ).rejects.toMatchObject({ code: "INVALID_AMOUNT" }); + + expect(signer.sign).not.toHaveBeenCalled(); + expect(client.submit).not.toHaveBeenCalled(); + }); + + it("rejects failed submit engine results and malformed signer hashes", async () => { + client.submit.mockResolvedValueOnce(submitResponse("tecNO_DST")); + const signer = createSigner(); + const depositor = createDepositor(signer); + + await expect( + depositor.submitDeposit({ + asset: XRPL_NATIVE_ASSET, + amount: 1n, + destination: { account: ACCOUNT }, + }), + ).rejects.toMatchObject({ code: "RPC_ERROR" }); + + signer.sign.mockResolvedValueOnce({ txBlob: TX_BLOB, hash: "not-a-hash" }); + await expect( + depositor.submitDeposit({ + asset: XRPL_NATIVE_ASSET, + amount: 1n, + destination: { account: ACCOUNT }, + }), + ).rejects.toMatchObject({ code: "INVALID_TX_REF" }); + }); + + it("maps XRPL tx lookup status to the shared deposit status", async () => { + const depositor = createDepositor(createSigner()); + + client.request.mockResolvedValueOnce(txResponse(true)); + await expect(depositor.verifyDeposit(HASH_REF, 0)).resolves.toBe("confirmed"); + + client.request.mockResolvedValueOnce(txResponse(false)); + await expect(depositor.verifyDeposit(HASH_REF, 1n)).resolves.toBe("pending"); + + client.request.mockRejectedValueOnce(new Error("txnNotFound")); + await expect(depositor.verifyDeposit(HASH_REF, 0)).resolves.toBe("absent"); + + const rpcError = new Error("node offline"); + client.request.mockRejectedValueOnce(rpcError); + await expect(depositor.verifyDeposit(HASH_REF, 0)).rejects.toMatchObject({ + code: "RPC_ERROR", + cause: rpcError, + }); + }); + + it("validates tx refs and min confirmations before tx lookup", async () => { + const depositor = createDepositor(createSigner()); + + await expect( + depositor.verifyDeposit({ hash: "0x1234" as Bytes32Hex, raw: HASH_RAW }, 0), + ).rejects.toMatchObject({ code: "INVALID_TX_REF" }); + await expect( + depositor.verifyDeposit({ hash: HASH_REF.hash, raw: "not-a-hash" }, 0), + ).rejects.toMatchObject({ code: "INVALID_TX_REF" }); + await expect(depositor.verifyDeposit(HASH_REF, -1)).rejects.toMatchObject({ + code: "INVALID_CONFIRMATIONS", + }); + + expect(client.request).not.toHaveBeenCalled(); + }); +}); + +function createDepositor( + signer = createSigner(), + overrides: Partial[0]> = {}, +): XrplVaultDepositor { + return new XrplVaultDepositor({ + rpcUrl: RPC_URL, + vaultAddress: VAULT_ADDRESS, + signer, + ...overrides, + }); +} + +function createClient(): MockXrplClient { + return { + connect: vi.fn(async () => undefined), + disconnect: vi.fn(async () => undefined), + isConnected: vi.fn(() => false), + autofill: vi.fn(async (payment) => preparedPayment(payment)), + submit: vi.fn(async () => submitResponse("tesSUCCESS")), + request: vi.fn(async () => txResponse(true)), + }; +} + +function createSigner(): MockSigner { + return { + classicAddress: DEPOSITOR_ADDRESS, + sign: vi.fn(async () => ({ txBlob: TX_BLOB, hash: HASH_RAW.toLowerCase() })), + }; +} + +function preparedPayment(payment: Partial = {}): Payment { + return { + TransactionType: "Payment", + Account: DEPOSITOR_ADDRESS, + Destination: VAULT_ADDRESS, + Amount: "10", + ...payment, + Fee: payment.Fee ?? "12", + Sequence: payment.Sequence ?? 1, + } as Payment; +} + +function submitResponse(engineResult: string): SubmitResponse { + return { + type: "response", + result: { + engine_result: engineResult, + engine_result_code: engineResult === "tesSUCCESS" ? 0 : -1, + engine_result_message: engineResult, + tx_blob: TX_BLOB, + tx_json: { TransactionType: "Payment" }, + accepted: true, + account_sequence_available: 1, + account_sequence_next: 2, + applied: engineResult === "tesSUCCESS", + broadcast: true, + kept: true, + queued: engineResult === "terQUEUED", + open_ledger_cost: "10", + validated_ledger_index: 1, + }, + } as unknown as SubmitResponse; +} + +function txResponse(validated: boolean): TxResponse { + return { + type: "response", + result: { + hash: HASH_RAW, + validated, + tx_json: { TransactionType: "Payment" }, + }, + } as unknown as TxResponse; +} From ed1283a31eb21b025ce47e28fedbba751a47171a Mon Sep 17 00:00:00 2001 From: Maharshi Mishra Date: Fri, 26 Jun 2026 15:23:42 +0530 Subject: [PATCH 2/3] fix(ts): finalize xrpl depositor review cleanup --- README.md | 146 +++++++- devnet/rippled.cfg | 4 +- pkg/blockchain/xrpl/client.go | 37 +++ pkg/blockchain/xrpl/depositor.go | 9 +- pkg/blockchain/xrpl/rotation_finalizer.go | 9 +- pkg/blockchain/xrpl/ticket.go | 6 +- pkg/blockchain/xrpl/vault_integration_test.go | 7 +- pkg/blockchain/xrpl/wire.go | 4 +- pkg/blockchain/xrpl/withdrawal_finalizer.go | 9 +- sdk/ts/README.md | 37 ++- sdk/ts/examples/solana-deposit/src/main.ts | 74 +++-- sdk/ts/examples/xrpl-deposit/README.md | 132 ++++++++ sdk/ts/examples/xrpl-deposit/index.html | 20 +- .../examples/xrpl-deposit/src/local-signer.ts | 38 +++ sdk/ts/examples/xrpl-deposit/src/main.ts | 312 ++++++++++++++++-- sdk/ts/examples/xrpl-deposit/vite.config.ts | 13 + sdk/ts/src/blockchain/sol/constants.ts | 2 + sdk/ts/src/blockchain/sol/depositor.ts | 226 ++++++++++--- sdk/ts/src/blockchain/sol/index.ts | 6 +- sdk/ts/src/blockchain/sol/types.ts | 6 + sdk/ts/src/blockchain/sol/validation.ts | 21 +- sdk/ts/src/blockchain/xrpl/depositor.ts | 46 ++- sdk/ts/src/blockchain/xrpl/types.ts | 11 +- sdk/ts/src/blockchain/xrpl/validation.ts | 21 +- sdk/ts/src/index.ts | 2 + .../sol/depositor.integration.test.ts | 54 ++- sdk/ts/test/blockchain/sol/depositor.test.ts | 278 ++++++++++++++-- .../xrpl/depositor.integration.test.ts | 16 + sdk/ts/test/blockchain/xrpl/depositor.test.ts | 55 ++- .../xrpl-deposit/local-signer.test.ts | 30 ++ 30 files changed, 1408 insertions(+), 223 deletions(-) create mode 100644 pkg/blockchain/xrpl/client.go create mode 100644 sdk/ts/examples/xrpl-deposit/README.md create mode 100644 sdk/ts/examples/xrpl-deposit/src/local-signer.ts create mode 100644 sdk/ts/examples/xrpl-deposit/vite.config.ts create mode 100644 sdk/ts/test/examples/xrpl-deposit/local-signer.test.ts diff --git a/README.md b/README.md index 802719f..e9345c5 100644 --- a/README.md +++ b/README.md @@ -1 +1,145 @@ -# clearnet-sdk \ No newline at end of file +# clearnet-sdk + +SDKs and shared protocol libraries for Clearnet integrations. + +This repository currently contains: + +- a Go module, `github.com/layer-3/clearnet-sdk`, with core protocol types, + signing helpers, p2p helpers, and blockchain adapters; +- a TypeScript package under `sdk/ts`, published/imported as + `@yellow-org/clearnet-sdk`; +- Docker-backed local devnet tooling for Go and TypeScript integration tests. + +The Go SDK is the broader backend-facing SDK. The TypeScript SDK currently +focuses on browser and application deposit flows for EVM, Solana, and XRPL. + +## Repository Layout + +| Path | Purpose | +|---|---| +| `pkg/core` | Shared Clearnet data types, operations, transaction references, deposit destinations, and adapter interfaces. | +| `pkg/blockchain/evm` | Go EVM adapters for vault deposits, withdrawals, signer rotation, registry/faucet/token/fraud interactions, and generated contract bindings. | +| `pkg/blockchain/sol` | Go Solana custody adapter code, program bindings, deposits, withdrawals, and signer rotation. | +| `pkg/blockchain/xrpl` | Go XRPL deposits, withdrawals, signer rotation, ticket handling, and payment wire helpers. | +| `pkg/blockchain/btc` | Go Bitcoin vault deposit, withdrawal, rotation, consolidation, and RPC helpers. | +| `pkg/decimal` | Decimal amount type used by Go chain adapters. | +| `pkg/bls`, `pkg/eip712`, `pkg/sign` | Signature and digest helpers. | +| `pkg/p2p`, `pkg/receipt`, `pkg/log` | Supporting networking, receipt, and logging packages. | +| `sdk/ts` | TypeScript SDK package, tests, and browser demos. See `sdk/ts/README.md`. | +| `devnet` | Docker Compose local blockchain devnet and readiness probe. See `devnet/README.md`. | + +## Go SDK + +The Go module is rooted at this repository: + +```sh +go get github.com/layer-3/clearnet-sdk +``` + +Common entry points: + +- `pkg/core`: chain-neutral interfaces such as `VaultDepositor`, + `VaultWithdrawalFinalizer`, `SignerRotationFinalizer`, `TxRef`, and + `DepositDestination`. +- `pkg/blockchain/evm`: EVM custody vault flows and generated bindings. +- `pkg/blockchain/sol`: Solana custody vault flows. +- `pkg/blockchain/xrpl`: XRPL custody vault flows. +- `pkg/blockchain/btc`: Bitcoin custody vault flows. + +Run the Go checks: + +```sh +make build +make lint +make test +``` + +Generated Go files are committed. Regenerate them after changing generation +inputs: + +```sh +make generate +``` + +## TypeScript SDK + +The TypeScript package lives in `sdk/ts` and is ESM-first. + +```sh +cd sdk/ts +npm ci +npm run typecheck +npm test +npm run build +``` + +Install from an application: + +```sh +npm install @yellow-org/clearnet-sdk +``` + +The package currently exposes vault depositors for: + +- EVM native ETH and ERC-20 deposits; +- Solana native SOL and SPL token deposits; +- XRPL native XRP and issued-currency deposits. + +Read the package guide and API examples in `sdk/ts/README.md`. + +## Browser Demos + +The TypeScript package includes local demo apps for manual wallet testing: + +```sh +npm --prefix sdk/ts run demo:evm +npm --prefix sdk/ts run demo:sol +npm --prefix sdk/ts run demo:xrpl +``` + +The demos expect a local or configured chain endpoint, funded wallet accounts, +and the chain-specific wallet/browser extension needed by the demo. They are +developer aids, not production app templates. + +## Devnet And Integration Tests + +The local devnet runs the chain nodes used by the integration suites: + +```sh +make devnet +npm --prefix sdk/ts ci +make integration +make devnet-down +``` + +Focused targets are available when iterating on one chain: + +```sh +make devnet-evm +npm --prefix sdk/ts run test:integration:evm + +make devnet-sol +npm --prefix sdk/ts run test:integration:sol + +make devnet-xrpl +npm --prefix sdk/ts run test:integration:xrpl +``` + +`make integration` runs the Go blockchain integrations and the TypeScript EVM, +Solana, and XRPL integration tests. See `devnet/README.md` for ports, +provisioning behavior, and environment overrides. + +## Development Notes + +- Use `make test` for the Go race-enabled test suite. +- Use `npm --prefix sdk/ts test` for TypeScript unit tests. +- Use `npm --prefix sdk/ts audit --omit=dev --audit-level=moderate` when + checking runtime dependency advisories for the TypeScript package. +- Keep generated files and vendored chain artifacts in sync with their source + inputs. +- Keep public SDK documentation broad: this repository supports Clearnet + integration surfaces, not only custody-specific flows. + +## License + +MIT. See `LICENSE`. diff --git a/devnet/rippled.cfg b/devnet/rippled.cfg index 3256f6d..f26b0e7 100644 --- a/devnet/rippled.cfg +++ b/devnet/rippled.cfg @@ -23,8 +23,10 @@ protocol = peer [node_size] tiny +# Keep this local id away from public Xahau ids. GemWallet and other wallets +# know 21337/21338 as Xahau mainnet/testnet. [network_id] -21337 +31337 [node_db] type=NuDB diff --git a/pkg/blockchain/xrpl/client.go b/pkg/blockchain/xrpl/client.go new file mode 100644 index 0000000..cd984a7 --- /dev/null +++ b/pkg/blockchain/xrpl/client.go @@ -0,0 +1,37 @@ +package xrpl + +import ( + "fmt" + + "github.com/Peersyst/xrpl-go/xrpl/queries/server" + "github.com/Peersyst/xrpl-go/xrpl/rpc" +) + +const xrplNetworkIDRequiredAbove = 1024 + +func newRPCClient(rpcURL string) (*rpc.Client, error) { + cfg, err := rpc.NewClientConfig(rpcURL) + if err != nil { + return nil, fmt.Errorf("xrpl: create rpc config: %w", err) + } + return rpc.NewClient(cfg), nil +} + +func ensureNetworkID(client *rpc.Client) error { + if client.NetworkID != 0 { + return nil + } + info, err := client.GetServerInfo(&server.InfoRequest{}) + if err != nil { + return fmt.Errorf("xrpl: server_info: %w", err) + } + networkID := info.Info.NetworkID + if networkID <= xrplNetworkIDRequiredAbove { + return nil + } + if networkID > uint(^uint32(0)) { + return fmt.Errorf("xrpl: network_id %d overflows uint32", networkID) + } + client.NetworkID = uint32(networkID) + return nil +} diff --git a/pkg/blockchain/xrpl/depositor.go b/pkg/blockchain/xrpl/depositor.go index 10cf454..5fe659c 100644 --- a/pkg/blockchain/xrpl/depositor.go +++ b/pkg/blockchain/xrpl/depositor.go @@ -32,15 +32,15 @@ var _ core.VaultDepositor = (*Depositor)(nil) // NewDepositor builds the XRPL depositor against the rippled JSON-RPC at rpcURL. func NewDepositor(rpcURL, vaultAddress string, signer sign.Signer) (*Depositor, error) { - cfg, err := rpc.NewClientConfig(rpcURL) + client, err := newRPCClient(rpcURL) if err != nil { - return nil, fmt.Errorf("xrpl: create rpc config: %w", err) + return nil, err } id, err := DeriveIdentity(signer) if err != nil { return nil, err } - return &Depositor{client: rpc.NewClient(cfg), vaultAddress: vaultAddress, signer: signer, id: id}, nil + return &Depositor{client: client, vaultAddress: vaultAddress, signer: signer, id: id}, nil } // DepositorAddress returns the depositor's classic r-address. @@ -69,6 +69,9 @@ func (d *Depositor) SubmitDeposit(ctx context.Context, asset string, amount deci Amount: xrplAmount, } flatTx := payment.Flatten() + if err := ensureNetworkID(d.client); err != nil { + return core.TxRef{}, err + } if err := d.client.Autofill(&flatTx); err != nil { return core.TxRef{}, fmt.Errorf("xrpl: autofill: %w", err) } diff --git a/pkg/blockchain/xrpl/rotation_finalizer.go b/pkg/blockchain/xrpl/rotation_finalizer.go index 0d5a9fd..32625fe 100644 --- a/pkg/blockchain/xrpl/rotation_finalizer.go +++ b/pkg/blockchain/xrpl/rotation_finalizer.go @@ -58,16 +58,16 @@ var _ core.SignerRotationFinalizer = (*RotationFinalizer)(nil) // current SignerQuorum (used to size the multi-sign fee and trim the quorum); // signer is one of the current SignerList members. func NewRotationFinalizer(rpcURL, vaultAddress string, threshold int, signer sign.Signer) (*RotationFinalizer, error) { - cfg, err := rpc.NewClientConfig(rpcURL) + client, err := newRPCClient(rpcURL) if err != nil { - return nil, fmt.Errorf("xrpl: create rpc config: %w", err) + return nil, err } id, err := DeriveIdentity(signer) if err != nil { return nil, err } return &RotationFinalizer{ - client: rpc.NewClient(cfg), + client: client, vaultAddress: vaultAddress, threshold: threshold, signer: signer, @@ -97,6 +97,9 @@ func (f *RotationFinalizer) Pack(ctx context.Context, _ [32]byte, newSigners []s return nil, fmt.Errorf("xrpl: resolve live quorum: %w", err) } } + if err := ensureNetworkID(f.client); err != nil { + return nil, err + } if err := f.client.AutofillMultisigned(&flatTx, uint64(quorum)); err != nil { return nil, fmt.Errorf("xrpl: autofill: %w", err) } diff --git a/pkg/blockchain/xrpl/ticket.go b/pkg/blockchain/xrpl/ticket.go index a6aa8ae..13785e8 100644 --- a/pkg/blockchain/xrpl/ticket.go +++ b/pkg/blockchain/xrpl/ticket.go @@ -31,12 +31,12 @@ var _ TicketProvider = (*LedgerTicketProvider)(nil) // NewLedgerTicketProvider builds a provider reading Tickets owned by // vaultAddress over the JSON-RPC at rpcURL. func NewLedgerTicketProvider(rpcURL, vaultAddress string) (*LedgerTicketProvider, error) { - cfg, err := rpc.NewClientConfig(rpcURL) + client, err := newRPCClient(rpcURL) if err != nil { - return nil, fmt.Errorf("xrpl: create rpc config: %w", err) + return nil, err } return &LedgerTicketProvider{ - client: rpc.NewClient(cfg), + client: client, account: types.Address(vaultAddress), }, nil } diff --git a/pkg/blockchain/xrpl/vault_integration_test.go b/pkg/blockchain/xrpl/vault_integration_test.go index 9b20d29..e43dc4f 100644 --- a/pkg/blockchain/xrpl/vault_integration_test.go +++ b/pkg/blockchain/xrpl/vault_integration_test.go @@ -52,11 +52,11 @@ func TestIntegrationXRPL_DepositAndWithdraw(t *testing.T) { defer cancel() url := xrplEnv("XRPL_RPC_URL", defaultXRPLRPC) - cfg, err := rpc.NewClientConfig(url) + client, err := newRPCClient(url) if err != nil { t.Fatalf("rpc config: %v", err) } - h := &xrplHarness{url: url, client: rpc.NewClient(cfg), http: &http.Client{Timeout: 30 * time.Second}} + h := &xrplHarness{url: url, client: client, http: &http.Client{Timeout: 30 * time.Second}} master := masterSigner(t) masterID := mustIdentity(t, master) @@ -200,6 +200,9 @@ type xrplHarness struct { // ledger so the tx validates before the next call reads account state. func (h *xrplHarness) submit(ctx context.Context, t *testing.T, s sign.Signer, id Identity, flatTx transaction.FlatTransaction) { t.Helper() + if err := ensureNetworkID(h.client); err != nil { + t.Fatalf("network id: %v", err) + } if err := h.client.Autofill(&flatTx); err != nil { t.Fatalf("autofill: %v", err) } diff --git a/pkg/blockchain/xrpl/wire.go b/pkg/blockchain/xrpl/wire.go index 585bd91..0f7db5e 100644 --- a/pkg/blockchain/xrpl/wire.go +++ b/pkg/blockchain/xrpl/wire.go @@ -36,7 +36,7 @@ const maxAcceptableFeeDrops uint64 = 1_000_000 var canonicalAllowedFields = map[string]struct{}{ "TransactionType": {}, "Account": {}, "Destination": {}, "Amount": {}, "InvoiceID": {}, "TicketSequence": {}, "Sequence": {}, "Fee": {}, - "SigningPubKey": {}, "Flags": {}, + "SigningPubKey": {}, "Flags": {}, "NetworkID": {}, } // Identity is a signer's XRPL classic address + signing pubkey hex. @@ -212,7 +212,7 @@ func ValidateCanonical(flat transaction.FlatTransaction, op *core.WithdrawalOp, // canonical SignerListSet flatTx before signing. var rotationAllowedFields = map[string]struct{}{ "TransactionType": {}, "Account": {}, "SignerQuorum": {}, "SignerEntries": {}, - "Sequence": {}, "Fee": {}, "SigningPubKey": {}, "Flags": {}, + "Sequence": {}, "Fee": {}, "SigningPubKey": {}, "Flags": {}, "NetworkID": {}, } // validateCanonicalRotation asserts the canonical SignerListSet flatTx rotates diff --git a/pkg/blockchain/xrpl/withdrawal_finalizer.go b/pkg/blockchain/xrpl/withdrawal_finalizer.go index 950eced..95532c5 100644 --- a/pkg/blockchain/xrpl/withdrawal_finalizer.go +++ b/pkg/blockchain/xrpl/withdrawal_finalizer.go @@ -56,16 +56,16 @@ var _ core.VaultWithdrawalFinalizer = (*WithdrawalFinalizer)(nil) // NewWithdrawalFinalizer builds the XRPL vault finalizer. threshold is the // SignerQuorum; tickets authorizes each withdrawal's TicketSequence. func NewWithdrawalFinalizer(rpcURL, vaultAddress string, threshold int, signer sign.Signer, tickets TicketProvider) (*WithdrawalFinalizer, error) { - cfg, err := rpc.NewClientConfig(rpcURL) + client, err := newRPCClient(rpcURL) if err != nil { - return nil, fmt.Errorf("xrpl: create rpc config: %w", err) + return nil, err } id, err := DeriveIdentity(signer) if err != nil { return nil, err } return &WithdrawalFinalizer{ - client: rpc.NewClient(cfg), + client: client, vaultAddress: vaultAddress, threshold: threshold, signer: signer, @@ -123,6 +123,9 @@ func (f *WithdrawalFinalizer) Pack(ctx context.Context, op *core.WithdrawalOp, w if err != nil { return nil, err } + if err := ensureNetworkID(f.client); err != nil { + return nil, err + } if err := f.client.AutofillMultisigned(&flatTx, uint64(quorum)); err != nil { return nil, fmt.Errorf("xrpl: autofill: %w", err) } diff --git a/sdk/ts/README.md b/sdk/ts/README.md index ec4ae45..c6dc583 100644 --- a/sdk/ts/README.md +++ b/sdk/ts/README.md @@ -138,13 +138,24 @@ import { SOLANA_NATIVE_ASSET, SolanaVaultDepositor, } from "@yellow-org/clearnet-sdk"; -import { Connection, Keypair, sendAndConfirmTransaction } from "@solana/web3.js"; +import { + Connection, + Keypair, + LAMPORTS_PER_SOL, + sendAndConfirmTransaction, +} from "@solana/web3.js"; import type { SolanaSigner } from "@yellow-org/clearnet-sdk"; 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) { @@ -239,8 +250,10 @@ const ref = await depositor.submitDeposit({ 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. Use -`verifyDeposit` to observe validated-ledger finality. +signed blob, and returns after rippled accepts the submit result as `tesSUCCESS` +or `terQUEUED`. Use `verifyDeposit` to observe validated-ledger finality; a +just-submitted XRPL payment can return `pending` until it appears in a validated +ledger. ## Deposit References @@ -380,10 +393,10 @@ XRPL 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.account` | `string` | 20-byte Clearnet account as hex, with optional `0x`. | | `destination.ref` | `` `0x${string}` \| undefined `` | Optional 32-byte opaque reference. | | `asset` | `string` | `XRP`/empty for native, or issued-currency `CUR.rIssuer` / `CUR:rIssuer`. | -| `amount` | `bigint \| string` | Native drops as `bigint`, issued-currency decimal value as `string`. | +| `amount` | `bigint \| string` | Native XRP uses drops as `bigint`; issued currencies use a decimal `string`. | For XRPL, `TxRef.raw` is the uppercase 64-hex transaction hash and `TxRef.hash` is the same bytes as `0x` hex. @@ -495,10 +508,12 @@ 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. -The XRPL demo uses GemWallet's browser API, asks the wallet to sign the prepared -payment, and submits the signed blob through the configured XRPL WebSocket URL. -The wallet must be funded, and issued-currency deposits require existing -trustlines and issued balances. +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. ## Troubleshooting @@ -511,9 +526,9 @@ Errors thrown by the SDK use `ClearnetSdkError` with a stable `code`. | `INVALID_CONFIRMATIONS` | `minConfirmations` is negative, fractional, or an unsafe number. | | `INVALID_REFERENCE` | `destination.ref` is not a 32-byte hex value. | | `INVALID_TX_REF` | `ref.hash` is not bytes32, Solana `ref.raw` is not a 64-byte signature, or XRPL `ref.raw` is not a 64-hex hash. | -| `MISSING_WALLET_ACCOUNT` | The EVM wallet account is missing/mismatched, or the Solana signer is missing. | +| `MISSING_WALLET_ACCOUNT` | The EVM wallet account is missing/mismatched, or the Solana/XRPL signer is missing. | | `CHAIN_MISMATCH` | EVM only: the public RPC or wallet chain does not match `chainId`. | -| `TX_REVERTED` | A submitted approval or deposit transaction reverted. | +| `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. | | `RPC_ERROR` | The public RPC or wallet provider returned an unexpected error. | diff --git a/sdk/ts/examples/solana-deposit/src/main.ts b/sdk/ts/examples/solana-deposit/src/main.ts index aa25097..70fb875 100644 --- a/sdk/ts/examples/solana-deposit/src/main.ts +++ b/sdk/ts/examples/solana-deposit/src/main.ts @@ -61,11 +61,13 @@ async function connectWallet(): Promise { setBusy(connectButton, true); try { const chain = readWalletChain(); + const rpcUrl = readInput("rpc-url"); + const commitment = readCommitment(); const wallet = requireWallet(chain); const result = await wallet.features[StandardConnect].connect(); const account = firstSupportedAccount(result.accounts, chain); - signer = new BrowserSolanaSigner(wallet, account); - const balance = await connection().getBalance(new PublicKey(account.address)); + signer = new BrowserSolanaSigner(wallet, account, rpcUrl, chain, commitment); + const balance = await signer.balance(); writeLog( `Connected ${wallet.name} ${account.address}\nWallet balance: ${balance} lamports`, ); @@ -84,16 +86,17 @@ async function submitDeposit(): Promise { return; } - const ref = readOptional("reference"); - const depositor = new SolanaVaultDepositor({ - rpcUrl: readInput("rpc-url"), - signer, - programId: readInput("program-id"), - commitment: readCommitment(), - }); - setBusy(submitButton, true); try { + signer.assertMatches(readInput("rpc-url"), readWalletChain(), readCommitment()); + const ref = readOptional("reference"); + const depositor = new SolanaVaultDepositor({ + rpcUrl: signer.rpcUrl, + signer, + programId: readInput("program-id"), + commitment: signer.commitment, + }); + lastRef = await depositor.submitDeposit( { destination: { @@ -125,15 +128,17 @@ async function verifyLastTx(): Promise { if (lastRef === undefined || signer === undefined) { return; } - const depositor = new SolanaVaultDepositor({ - rpcUrl: readInput("rpc-url"), - signer, - programId: readInput("program-id"), - commitment: readCommitment(), - }); setBusy(verifyButton, true); try { + signer.assertMatches(readInput("rpc-url"), readWalletChain(), readCommitment()); + const depositor = new SolanaVaultDepositor({ + rpcUrl: signer.rpcUrl, + signer, + programId: readInput("program-id"), + commitment: signer.commitment, + }); + const status = await depositor.verifyDeposit(lastRef, 0); writeLog(`Verify ${lastRef.raw}\nstatus: ${status}`); } catch (error) { @@ -147,38 +152,59 @@ class BrowserSolanaSigner implements SolanaSigner { constructor( private readonly wallet: StandardSolanaWallet, private readonly account: WalletAccount, + readonly rpcUrl: string, + private readonly chain: SolanaWalletChain, + readonly commitment: SolanaCommitment, ) {} get publicKey(): string { return this.account.address; } + async balance(): Promise { + return await this.connection().getBalance(new PublicKey(this.publicKey)); + } + + assertMatches( + rpcUrl: string, + chain: SolanaWalletChain, + commitment: SolanaCommitment, + ): void { + if ( + rpcUrl !== this.rpcUrl || + chain !== this.chain || + commitment !== this.commitment + ) { + throw new Error("network settings changed after wallet connection; reconnect wallet"); + } + } + async signAndSend(transaction: Transaction): Promise { - const latest = await connection().getLatestBlockhash(readCommitment()); + const latest = await this.connection().getLatestBlockhash(this.commitment); transaction.recentBlockhash = latest.blockhash; transaction.feePayer ??= new PublicKey(this.publicKey); const [result] = await this.wallet.features[SolanaSignTransaction].signTransaction({ account: this.account, - chain: readWalletChain(), + chain: this.chain, transaction: transaction.serialize({ requireAllSignatures: false, verifySignatures: false, }), options: { - preflightCommitment: readCommitment(), + preflightCommitment: this.commitment, }, }); if (result?.signedTransaction === undefined) { throw new Error("wallet did not return a signed transaction"); } - return await connection().sendRawTransaction(result.signedTransaction, { - preflightCommitment: readCommitment(), + return await this.connection().sendRawTransaction(result.signedTransaction, { + preflightCommitment: this.commitment, }); } -} -function connection(): Connection { - return new Connection(readInput("rpc-url"), readCommitment()); + private connection(): Connection { + return new Connection(this.rpcUrl, this.commitment); + } } function readCommitment(): SolanaCommitment { diff --git a/sdk/ts/examples/xrpl-deposit/README.md b/sdk/ts/examples/xrpl-deposit/README.md new file mode 100644 index 0000000..7849eef --- /dev/null +++ b/sdk/ts/examples/xrpl-deposit/README.md @@ -0,0 +1,132 @@ +# XRPL Deposit Demo + +This browser demo exercises `XrplVaultDepositor` against the local standalone +XRPL devnet. It supports two signer paths: + +- a local XRPL wallet generated in the browser for the fastest local smoke test +- GemWallet signing through a custom WSS endpoint that points at the same local + chain + +The local devnet uses `network_id: 31337`. Do not change it to `21337` or +`21338`: those IDs are used by Xahau mainnet and testnet, and wallets may treat +the local chain as a Xahau network if those IDs are reused. + +## Start The Demo + +From the repository root: + +```sh +make devnet-xrpl +``` + +From `sdk/ts`: + +```sh +npm run demo:xrpl +``` + +Open `http://127.0.0.1:5173/`. + +The default fields are for the local devnet: + +| Field | Default | Purpose | +|---|---|---| +| WebSocket URL | `ws://127.0.0.1:6006` | XRPL WebSocket URL used by the SDK. | +| Admin HTTP URL | `/xrpl-admin` | Vite proxy to `http://127.0.0.1:5005` for `ledger_accept`. | +| Vault Address | `rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh` | Standalone genesis account used as the demo vault. | +| Fund Drops | `1000000000` | Amount sent from the standalone genesis wallet to the selected signer. | + +## Local Signer Flow + +Use this path first when checking that the devnet, SDK, and demo are working. +It does not require a browser wallet. + +1. Leave `WebSocket URL` as `ws://127.0.0.1:6006`. +2. Click `Use Local Signer`. +3. Click `Fund Wallet`. +4. Click `Submit Deposit`. +5. Click `Verify Last Tx`. + +Expected result: + +```text +status: confirmed +``` + +If the `Local Wallet Seed` field is blank, the demo generates a fresh local +wallet and writes the seed back into the field. Reuse that seed if you need to +repeat the same local-wallet test after a page reload. + +## GemWallet Flow + +GemWallet custom networks require a `wss://` endpoint. The local rippled +container exposes raw WebSocket at `ws://127.0.0.1:6006`, so the wallet cannot +use that URL directly. Put a trusted WSS tunnel or TLS reverse proxy in front of +the local WebSocket port. + +One local option is ngrok: + +```sh +ngrok http 6006 +``` + +If ngrok prints `https://example.ngrok-free.app`, use +`wss://example.ngrok-free.app` as the wallet and demo WebSocket URL. + +Then: + +1. In GemWallet, add a custom network for the WSS endpoint. +2. Select that custom network in GemWallet. +3. In the demo, set `WebSocket URL` to the same WSS endpoint. +4. Keep `Admin HTTP URL` as `/xrpl-admin`. +5. Click `Connect GemWallet` and approve the address request. +6. Click `Fund Wallet`. +7. Click `Submit Deposit` and approve the signing request in GemWallet. +8. Click `Verify Last Tx`. + +Expected result: + +```text +status: confirmed +``` + +The demo checks GemWallet's selected WebSocket endpoint before signing. If the +wallet is still on Xahau testnet or another network, the demo reports both +network IDs and stops before opening the signing request. + +## What Went Wrong During Setup + +There were three separate issues: + +1. The first local chain ID collided with Xahau. `21337` and `21338` are public + Xahau IDs, so the local standalone chain now uses `31337`. +2. GemWallet would not connect to `ws://127.0.0.1:6006` as a custom network. + The wallet-facing endpoint must be `wss://`, so the demo needs a WSS tunnel + or TLS proxy for the GemWallet path. +3. GemWallet 3.8.2 failed to render its signing review for prepared + transactions that already included the custom `NetworkID: 31337` field. + +The demo handles the third issue by verifying that GemWallet and the demo are +pointed at endpoints with the same `network_id`, then omitting `NetworkID` only +from the transaction object passed into `signTransaction()`. GemWallet autofills +`NetworkID` from its selected custom endpoint before signing. The submitted +transaction is still expected to include `NetworkID: 31337`; verify this through +`tx` lookup if the wallet flow is changed. + +## Troubleshooting + +| Symptom | Likely Cause | Fix | +|---|---|---| +| `GemWallet is on XAHAU Testnet ... The demo RPC is ... 31337` | GemWallet is not on the local custom network. | Select the custom WSS network in GemWallet and set the demo `WebSocket URL` to the same WSS URL. | +| GemWallet custom network form rejects the URL | The endpoint is `ws://`, not `wss://`. | Put ngrok, Caddy, or another trusted TLS proxy in front of `127.0.0.1:6006`. | +| `GemWallet address approval timed out` | The extension did not return an address approval result. | Reopen GemWallet, unlock it if needed, reload the demo, and retry `Connect GemWallet`. | +| `status: pending` immediately after submit | The transaction is accepted but not in a validated ledger yet. | The demo calls `ledger_accept`; click `Verify Last Tx` again if needed. | +| `actNotFound` or account lookup failure | The signer account is not funded on the local standalone ledger. | Click `Fund Wallet`, then submit again. | +| GemWallet signing window shows an error before approval | The wallet path may be receiving a prepared transaction shape GemWallet cannot render. | Confirm the demo is using the current code path that removes `NetworkID` only for GemWallet signing, then retry after reloading the page. | + +## Local-Only Notes + +The `Fund Wallet` button and `ledger_accept` call are for the repository's local +standalone devnet. They are not public XRPL or Xahau testnet flows. On a public +network, fund the wallet through that network's faucet or normal account +funding process and remove the standalone admin assumptions. diff --git a/sdk/ts/examples/xrpl-deposit/index.html b/sdk/ts/examples/xrpl-deposit/index.html index 7d2e9e8..ee66c93 100644 --- a/sdk/ts/examples/xrpl-deposit/index.html +++ b/sdk/ts/examples/xrpl-deposit/index.html @@ -138,6 +138,22 @@

XRPL Deposit Demo

Max Fee Drops + + + + +
+ Signer +
@@ -161,7 +177,9 @@

XRPL Deposit Demo

- + + +
diff --git a/sdk/ts/examples/xrpl-deposit/src/local-signer.ts b/sdk/ts/examples/xrpl-deposit/src/local-signer.ts new file mode 100644 index 0000000..b761c50 --- /dev/null +++ b/sdk/ts/examples/xrpl-deposit/src/local-signer.ts @@ -0,0 +1,38 @@ +import { Wallet, hashes, type SubmittableTransaction } from "xrpl"; + +import type { + XrplPreparedPayment, + XrplSignedTransaction, + XrplSigner, +} from "@yellow-org/clearnet-sdk"; + +export const LOCAL_XRPL_GENESIS_SEED = "snoPBrXtMeMyMHUVTgbuqAfg1SUTb"; + +export class LocalXrplSigner implements XrplSigner { + constructor(private readonly wallet: Wallet) {} + + get classicAddress(): string { + return this.wallet.classicAddress; + } + + get seed(): string | undefined { + return this.wallet.seed; + } + + async sign(payment: XrplPreparedPayment): Promise { + const signed = this.wallet.sign(payment as SubmittableTransaction); + return { + txBlob: signed.tx_blob, + hash: hashes.hashSignedTx(signed.tx_blob), + }; + } +} + +export function createLocalXrplSigner(seed?: string): LocalXrplSigner { + const normalized = seed?.trim(); + const wallet = + normalized === undefined || normalized === "" + ? Wallet.generate() + : Wallet.fromSeed(normalized); + return new LocalXrplSigner(wallet); +} diff --git a/sdk/ts/examples/xrpl-deposit/src/main.ts b/sdk/ts/examples/xrpl-deposit/src/main.ts index 2012e4d..59e0ced 100644 --- a/sdk/ts/examples/xrpl-deposit/src/main.ts +++ b/sdk/ts/examples/xrpl-deposit/src/main.ts @@ -1,5 +1,6 @@ import { getAddress, + getNetwork, isInstalled, signTransaction, } from "@gemwallet/api"; @@ -13,19 +14,48 @@ import type { XrplPreparedPayment, XrplSigner, } from "@yellow-org/clearnet-sdk"; -import { hashes, type SubmittableTransaction } from "xrpl"; +import { Client, Wallet, hashes, type SubmittableTransaction } from "xrpl"; +import { + createLocalXrplSigner, + LOCAL_XRPL_GENESIS_SEED, +} from "./local-signer.js"; const form = mustElement("deposit-form"); -const connectButton = mustElement("connect"); +const localSignerButton = mustElement("connect-local"); +const gemWalletButton = mustElement("connect-gemwallet"); +const fundButton = mustElement("fund"); const submitButton = mustElement("submit"); const verifyButton = mustElement("verify"); const logOutput = mustElement("log"); -let signer: GemWalletSigner | undefined; +let signer: XrplSigner | undefined; let lastRef: TxRef | undefined; -connectButton.addEventListener("click", () => { - void connectWallet(); +const GEMWALLET_NETWORK_TIMEOUT_MS = 8_000; +const GEMWALLET_ADDRESS_TIMEOUT_MS = 60_000; +const NETWORK_PROBE_TIMEOUT_MS = 5_000; + +type GemWalletNetwork = { + chain: string; + network: string; + websocket: string; +}; + +type NetworkIdentity = { + networkId: number; + url: string; +}; + +localSignerButton.addEventListener("click", () => { + connectLocalSigner(); +}); + +gemWalletButton.addEventListener("click", () => { + void connectGemWallet(); +}); + +fundButton.addEventListener("click", () => { + void fundWallet(); }); form.addEventListener("submit", (event) => { @@ -37,57 +67,145 @@ verifyButton.addEventListener("click", () => { void verifyLastTx(); }); -writeLog("Connect GemWallet to submit an XRPL deposit."); +writeLog("Use a local signer, fund it, then submit an XRPL deposit."); -async function connectWallet(): Promise { - setBusy(connectButton, true); +function connectLocalSigner(): void { + setBusy(localSignerButton, true); try { + const localSigner = createLocalXrplSigner(readOptional("local-seed")); + signer = localSigner; + if (localSigner.seed !== undefined) { + setInput("local-seed", localSigner.seed); + } + writeLog( + `Using local signer ${localSigner.classicAddress}\n` + + "Click Fund Wallet before submitting on the local devnet.", + ); + } catch (error) { + writeError(error); + } finally { + setBusy(localSignerButton, false); + } +} + +async function connectGemWallet(): Promise { + setBusy(gemWalletButton, true); + try { + writeLog("Waiting for GemWallet address approval..."); const installed = await isInstalled(); if (installed.result.isInstalled !== true) { throw new Error("GemWallet extension is not installed"); } - const response = await getAddress(); + const response = await withTimeout( + getAddress(), + "GemWallet address approval", + GEMWALLET_ADDRESS_TIMEOUT_MS, + ); const address = response.result?.address; if (address === undefined || address === "") { throw new Error("GemWallet did not return an address"); } signer = new GemWalletSigner(address); - writeLog(`Connected GemWallet ${address}`); + writeLog( + `Connected GemWallet ${address}\n` + + "Network will be verified before signing.", + ); } catch (error) { writeError(error); } finally { - setBusy(connectButton, false); + setBusy(gemWalletButton, false); } } -async function submitDeposit(): Promise { +async function fundWallet(): Promise { if (signer === undefined) { - await connectWallet(); + connectLocalSigner(); } if (signer === undefined) { return; } - const ref = readOptional("reference"); - const maxFeeDrops = readOptional("max-fee-drops"); - const depositor = new XrplVaultDepositor({ - rpcUrl: readInput("rpc-url"), - vaultAddress: readInput("vault-address"), - signer, - ...(maxFeeDrops === undefined ? {} : { maxFeeDrops: BigInt(maxFeeDrops) }), - }); + setBusy(fundButton, true); + const client = new Client(readInput("rpc-url")); + try { + const master = Wallet.fromSeed(LOCAL_XRPL_GENESIS_SEED); + await client.connect(); + const prepared = await client.autofill({ + TransactionType: "Payment", + Account: master.classicAddress, + Destination: signer.classicAddress, + Amount: readInput("fund-drops"), + }); + const signed = master.sign(prepared); + const result = await client.submit(signed.tx_blob, { autofill: false }); + const engineResult = result.result.engine_result; + if (engineResult !== "tesSUCCESS" && engineResult !== "terQUEUED") { + throw new Error(`Fund rejected: ${engineResult}`); + } + await ledgerAccept(); + const account = await client.request({ + command: "account_info", + account: signer.classicAddress, + ledger_index: "validated", + }); + writeLog( + `Funded ${signer.classicAddress}\n` + + `hash: ${signed.hash}\n` + + `balance: ${account.result.account_data.Balance} drops`, + ); + } catch (error) { + writeError(error); + } finally { + if (client.isConnected()) { + await client.disconnect(); + } + setBusy(fundButton, false); + } +} + +async function submitDeposit(): Promise { + if (signer === undefined) { + connectLocalSigner(); + } + if (signer === undefined) { + return; + } setBusy(submitButton, true); try { + if (signer instanceof GemWalletSigner) { + await assertGemWalletMatchesApp(); + } + + const ref = readOptional("reference"); + const maxFeeDrops = readOptional("max-fee-drops"); + const depositor = new XrplVaultDepositor({ + rpcUrl: readInput("rpc-url"), + vaultAddress: readInput("vault-address"), + signer, + ...(maxFeeDrops === undefined + ? {} + : { maxFeeDrops: BigInt(maxFeeDrops) }), + }); + const asset = readInput("asset"); lastRef = await depositor.submitDeposit( - { - destination: { - account: readInput("account"), - ...(ref === undefined ? {} : { ref: ref as Bytes32Hex }), - }, - asset: readInput("asset"), - amount: readAmount(), - }, + isNativeAsset(asset) + ? { + destination: { + account: readInput("account"), + ...(ref === undefined ? {} : { ref: ref as Bytes32Hex }), + }, + asset: asset === "" ? "" : XRPL_NATIVE_ASSET, + amount: BigInt(readInput("amount")), + } + : { + destination: { + account: readInput("account"), + ...(ref === undefined ? {} : { ref: ref as Bytes32Hex }), + }, + asset: asset as `${string}.${string}` | `${string}:${string}`, + amount: readInput("amount"), + }, { onSubmitted(ref) { lastRef = ref; @@ -96,6 +214,7 @@ async function submitDeposit(): Promise { }, }, ); + await ledgerAccept(); verifyButton.disabled = false; writeLog(`Accepted ${lastRef.raw}\nhash: ${lastRef.hash}`); } catch (error) { @@ -129,12 +248,34 @@ async function verifyLastTx(): Promise { } } +async function ledgerAccept(): Promise { + const response = await fetch(readInput("admin-rpc-url"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ method: "ledger_accept", params: [] }), + }); + if (!response.ok) { + throw new Error(`ledger_accept failed with HTTP ${response.status}`); + } + const body = (await response.json()) as { + result?: { status?: string }; + error?: unknown; + }; + if (body.result?.status !== "success") { + throw new Error(`ledger_accept failed: ${JSON.stringify(body)}`); + } +} + class GemWalletSigner implements XrplSigner { constructor(readonly classicAddress: string) {} async sign(payment: XrplPreparedPayment): Promise<{ txBlob: string; hash: string }> { + const transaction = { ...(payment as SubmittableTransaction) }; + // GemWallet 3.8.2 crashes its review UI for custom NetworkID values, but + // it autofills NetworkID from the selected custom endpoint during signing. + delete (transaction as { NetworkID?: number }).NetworkID; const response = await signTransaction({ - transaction: payment as SubmittableTransaction, + transaction, }); const txBlob = response.result?.signature; if (txBlob == null || txBlob === "") { @@ -147,19 +288,118 @@ class GemWalletSigner implements XrplSigner { } } -function readAmount(): bigint | string { - const asset = readInput("asset"); - const amount = readInput("amount"); - if (asset === "" || asset.toUpperCase() === XRPL_NATIVE_ASSET) { - return BigInt(amount); +async function assertGemWalletMatchesApp(): Promise<{ + walletNetwork: GemWalletNetwork; + appIdentity: NetworkIdentity; + walletIdentity: NetworkIdentity; +}> { + const walletNetwork = await getGemWalletNetwork(); + writeLog( + `GemWallet selected ${describeGemWalletNetwork(walletNetwork)}\n` + + "Checking network IDs...", + ); + const [appIdentity, walletIdentity] = await Promise.all([ + getNetworkIdentity(readInput("rpc-url")), + getNetworkIdentity(walletNetwork.websocket), + ]); + + if (walletIdentity.networkId !== appIdentity.networkId) { + throw new Error( + `GemWallet is on ${describeGemWalletNetwork(walletNetwork)} ` + + `(network_id ${walletIdentity.networkId}).\n` + + `The demo RPC is ${appIdentity.url} ` + + `(network_id ${appIdentity.networkId}).\n` + + "Switch GemWallet to a custom WSS endpoint for this chain, or use the local signer.", + ); + } + + return { walletNetwork, appIdentity, walletIdentity }; +} + +async function getGemWalletNetwork(): Promise { + const response = await withTimeout( + getNetwork(), + "GemWallet network check", + GEMWALLET_NETWORK_TIMEOUT_MS, + ); + const result = response.result; + if ( + result === undefined || + result.websocket === undefined || + result.websocket === "" + ) { + throw new Error("GemWallet did not return its selected network"); + } + return { + chain: String(result.chain), + network: String(result.network), + websocket: result.websocket, + }; +} + +async function getNetworkIdentity(url: string): Promise { + const client = new Client(url); + try { + await withTimeout( + client.connect(), + `Connect to ${url}`, + NETWORK_PROBE_TIMEOUT_MS, + ); + const response = await withTimeout( + client.request({ command: "server_info" }), + `server_info for ${url}`, + NETWORK_PROBE_TIMEOUT_MS, + ); + const info = response.result.info as { network_id?: number }; + return { + networkId: info.network_id ?? 0, + url, + }; + } finally { + if (client.isConnected()) { + await client.disconnect(); + } + } +} + +function describeGemWalletNetwork(network: GemWalletNetwork): string { + return `${network.chain} ${network.network} (${network.websocket})`; +} + +async function withTimeout( + operation: Promise, + label: string, + timeoutMs: number, +): Promise { + let timeoutId: ReturnType | undefined; + try { + return await Promise.race([ + operation, + new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject(new Error(`${label} timed out after ${timeoutMs}ms`)); + }, timeoutMs); + }), + ]); + } finally { + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } } - return amount; +} + +function isNativeAsset(asset: string): boolean { + return asset === "" || asset.toUpperCase() === XRPL_NATIVE_ASSET; } function readInput(id: string): string { return mustElement(id).value.trim(); } +function setInput(id: string, value: string): void { + mustElement(id).value = value; +} + function readOptional(id: string): string | undefined { const value = readInput(id); return value === "" ? undefined : value; diff --git a/sdk/ts/examples/xrpl-deposit/vite.config.ts b/sdk/ts/examples/xrpl-deposit/vite.config.ts new file mode 100644 index 0000000..862c928 --- /dev/null +++ b/sdk/ts/examples/xrpl-deposit/vite.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "vite"; + +export default defineConfig({ + server: { + proxy: { + "/xrpl-admin": { + target: "http://127.0.0.1:5005", + changeOrigin: true, + rewrite: () => "/", + }, + }, + }, +}); diff --git a/sdk/ts/src/blockchain/sol/constants.ts b/sdk/ts/src/blockchain/sol/constants.ts index 9fcea44..59bf201 100644 --- a/sdk/ts/src/blockchain/sol/constants.ts +++ b/sdk/ts/src/blockchain/sol/constants.ts @@ -16,6 +16,8 @@ export const DEFAULT_SOLANA_COMMITMENT = "finalized"; export const DEFAULT_RECEIPT_TIMEOUT_MS = 60_000; +export const POLL_INTERVAL_MS = 250; + export const DEPOSIT_SOL_DISCRIMINATOR = [ 108, 81, 78, 117, 125, 155, 56, 200, ] as const; diff --git a/sdk/ts/src/blockchain/sol/depositor.ts b/sdk/ts/src/blockchain/sol/depositor.ts index a099bdb..f726506 100644 --- a/sdk/ts/src/blockchain/sol/depositor.ts +++ b/sdk/ts/src/blockchain/sol/depositor.ts @@ -19,6 +19,7 @@ import { DEFAULT_RECEIPT_TIMEOUT_MS, DEPOSIT_SOL_DISCRIMINATOR, DEPOSIT_SPL_DISCRIMINATOR, + POLL_INTERVAL_MS, SOLANA_ASSOCIATED_TOKEN_PROGRAM_ID, SOLANA_CUSTODY_PROGRAM_ID, SOLANA_TOKEN_PROGRAM_ID, @@ -37,6 +38,7 @@ import { publicKeyFromString, requireAmount, requireClearnetAccount, + requireDepositDestination, requireProgramId, requireReceiptTimeout, requireReference, @@ -50,28 +52,39 @@ type SignatureStatusValue = Awaited< ReturnType >["value"][number]; +const SOLANA_CUSTODY_PUBLIC_KEY = new PublicKey(SOLANA_CUSTODY_PROGRAM_ID); +const SOLANA_TOKEN_PROGRAM_PUBLIC_KEY = new PublicKey(SOLANA_TOKEN_PROGRAM_ID); +const SOLANA_ASSOCIATED_TOKEN_PROGRAM_PUBLIC_KEY = new PublicKey( + SOLANA_ASSOCIATED_TOKEN_PROGRAM_ID, +); +const VAULT_SEED = new TextEncoder().encode("vault"); +const EVENT_AUTHORITY_SEED = new TextEncoder().encode("__event_authority"); + export class SolanaVaultDepositor implements VaultDepositor { - private readonly rpcUrl: string; private readonly signer: SolanaSigner; private readonly depositor: PublicKey; private readonly programId: PublicKey; private readonly commitment: SolanaCommitment; private readonly receiptTimeoutMs: number; private readonly connection: Connection; + private readonly vault: PublicKey; + private readonly eventAuthority: PublicKey; constructor(config: SolanaDepositorConfig) { - this.rpcUrl = requireRpcUrl(config.rpcUrl); + const rpcUrl = requireRpcUrl(config.rpcUrl); this.signer = requireSigner(config.signer); this.depositor = publicKeyFromString(this.signer.publicKey, "signer.publicKey"); this.programId = requireProgramId(config.programId); + this.vault = vaultPda(this.programId); + this.eventAuthority = eventAuthorityPda(this.programId); this.commitment = normalizeCommitment(config.commitment); this.receiptTimeoutMs = config.receiptTimeoutMs === undefined ? DEFAULT_RECEIPT_TIMEOUT_MS : requireReceiptTimeout(config.receiptTimeoutMs); - this.connection = new Connection(this.rpcUrl, { + this.connection = new Connection(rpcUrl, { commitment: this.commitment, fetch: (input, init) => globalThis.fetch(input, init), }); @@ -81,10 +94,17 @@ export class SolanaVaultDepositor input: SolanaSubmitDepositInput, options: SubmitDepositOptions = {}, ): Promise { - const account = requireClearnetAccount(input.destination.account); - const reference = requireReference(input.destination.ref); - const amount = requireAmount(input.amount); - const mint = resolveMint(input.asset); + const waitOptions = requireSubmitDepositOptions(options); + const fields = + input && typeof input === "object" + ? (input as Partial) + : {}; + const destination = requireDepositDestination(fields.destination); + const account = requireClearnetAccount(destination.account); + const reference = requireReference(destination.ref); + const amount = requireAmount(fields.amount); + const mint = resolveMint(fields.asset); + validateWaitOptions(waitOptions); const transaction = new Transaction(); transaction.feePayer = this.depositor; transaction.add( @@ -95,8 +115,8 @@ export class SolanaVaultDepositor const signature = await this.signAndSend(transaction); const ref = txRef(signature); - options.onSubmitted?.(ref); - await this.waitForCommitment(signature, ref, options); + waitOptions.onSubmitted?.(ref); + await this.waitForCommitment(signature, ref, waitOptions); return ref; } @@ -106,7 +126,7 @@ export class SolanaVaultDepositor ): Promise { requireTxRef(ref); const minConf = normalizeMinConfirmations(minConfirmations); - const status = await this.getSignatureStatus(ref.raw, undefined); + const status = await this.getSignatureStatus(ref.raw, ref); return mapStatus(status, minConf); } @@ -119,10 +139,10 @@ export class SolanaVaultDepositor programId: this.programId, keys: [ { pubkey: this.depositor, isSigner: true, isWritable: true }, - { pubkey: vaultPda(this.programId), isSigner: false, isWritable: true }, + { pubkey: this.vault, isSigner: false, isWritable: true }, { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, { - pubkey: eventAuthorityPda(this.programId), + pubkey: this.eventAuthority, isSigner: false, isWritable: false, }, @@ -143,7 +163,6 @@ export class SolanaVaultDepositor reference: Uint8Array, amount: bigint, ): TransactionInstruction { - const vault = vaultPda(this.programId); return new TransactionInstruction({ programId: this.programId, keys: [ @@ -154,24 +173,24 @@ export class SolanaVaultDepositor isSigner: false, isWritable: true, }, - { pubkey: vault, isSigner: false, isWritable: false }, + { pubkey: this.vault, isSigner: false, isWritable: false }, { - pubkey: associatedTokenAddress(vault, mint), + pubkey: associatedTokenAddress(this.vault, mint), isSigner: false, isWritable: true, }, { - pubkey: new PublicKey(SOLANA_TOKEN_PROGRAM_ID), + pubkey: SOLANA_TOKEN_PROGRAM_PUBLIC_KEY, isSigner: false, isWritable: false, }, { - pubkey: new PublicKey(SOLANA_ASSOCIATED_TOKEN_PROGRAM_ID), + pubkey: SOLANA_ASSOCIATED_TOKEN_PROGRAM_PUBLIC_KEY, isSigner: false, isWritable: false, }, { - pubkey: eventAuthorityPda(this.programId), + pubkey: this.eventAuthority, isSigner: false, isWritable: false, }, @@ -204,10 +223,9 @@ export class SolanaVaultDepositor ref: TxRef, options: SubmitDepositOptions, ): Promise { - const timeoutMs = options.receiptTimeoutMs ?? this.receiptTimeoutMs; - if (options.receiptTimeoutMs !== undefined) { - requireReceiptTimeout(options.receiptTimeoutMs); - } + const timeoutMs = requireReceiptTimeout( + options.receiptTimeoutMs ?? this.receiptTimeoutMs, + ); const deadline = Date.now() + timeoutMs; for (;;) { if (options.signal?.aborted === true) { @@ -215,7 +233,12 @@ export class SolanaVaultDepositor txRef: ref, }); } - const status = await this.getSignatureStatus(signature, ref); + const status = await waitWithControls( + () => this.getSignatureStatus(signature, ref), + remainingMs(deadline, ref), + options.signal, + ref, + ); if (status?.err != null) { throw new ClearnetSdkError("TX_REVERTED", "sol: transaction failed", { txRef: ref, @@ -229,7 +252,7 @@ export class SolanaVaultDepositor txRef: ref, }); } - await sleep(250, options.signal); + await sleep(Math.min(POLL_INTERVAL_MS, remainingMs(deadline, ref)), options.signal, ref); } } @@ -252,35 +275,36 @@ export class SolanaVaultDepositor } } -export function vaultPda(programId = new PublicKey(SOLANA_CUSTODY_PROGRAM_ID)): PublicKey { - return PublicKey.findProgramAddressSync( - [new TextEncoder().encode("vault")], - programId, - )[0]; +export function vaultPda(programId = SOLANA_CUSTODY_PUBLIC_KEY): PublicKey { + return PublicKey.findProgramAddressSync([VAULT_SEED], programId)[0]; } export function eventAuthorityPda( - programId = new PublicKey(SOLANA_CUSTODY_PROGRAM_ID), + programId = SOLANA_CUSTODY_PUBLIC_KEY, ): PublicKey { - return PublicKey.findProgramAddressSync( - [new TextEncoder().encode("__event_authority")], - programId, - )[0]; + return PublicKey.findProgramAddressSync([EVENT_AUTHORITY_SEED], programId)[0]; } function associatedTokenAddress(owner: PublicKey, mint: PublicKey): PublicKey { return PublicKey.findProgramAddressSync( [ owner.toBytes(), - new PublicKey(SOLANA_TOKEN_PROGRAM_ID).toBytes(), + SOLANA_TOKEN_PROGRAM_PUBLIC_KEY.toBytes(), mint.toBytes(), ], - new PublicKey(SOLANA_ASSOCIATED_TOKEN_PROGRAM_ID), + SOLANA_ASSOCIATED_TOKEN_PROGRAM_PUBLIC_KEY, )[0]; } function txRef(signature: string): TxRef { - const signatureBytes = bs58.decode(signature); + let signatureBytes: Uint8Array; + try { + signatureBytes = bs58.decode(signature); + } catch (error) { + throw new ClearnetSdkError("INVALID_TX_REF", "Solana signature must be base58", { + cause: error, + }); + } if (signatureBytes.length !== 64) { throw new ClearnetSdkError( "INVALID_TX_REF", @@ -326,20 +350,128 @@ function statusSatisfiesCommitment( return status.confirmationStatus === "finalized"; } -async function sleep(ms: number, signal: AbortSignal | undefined): Promise { +function validateWaitOptions(options: SubmitDepositOptions): void { + if (options.receiptTimeoutMs !== undefined) { + requireReceiptTimeout(options.receiptTimeoutMs); + } + if (options.signal?.aborted === true) { + throw new ClearnetSdkError("RECEIPT_TIMEOUT", "sol: receipt aborted"); + } +} + +function requireSubmitDepositOptions(options: unknown): SubmitDepositOptions { + if (options === null || typeof options !== "object") { + throw new ClearnetSdkError( + "RECEIPT_TIMEOUT", + "submit options must be an object", + ); + } + return options; +} + +function remainingMs(deadline: number, ref: TxRef): number { + const remaining = deadline - Date.now(); + if (remaining <= 0) { + throw new ClearnetSdkError("RECEIPT_TIMEOUT", "sol: receipt timeout", { + txRef: ref, + }); + } + return remaining; +} + +async function waitWithControls( + wait: () => Promise, + timeoutMs: number, + signal: AbortSignal | undefined, + ref: TxRef, +): Promise { + if (signal?.aborted === true) { + throw new ClearnetSdkError("RECEIPT_TIMEOUT", "sol: receipt aborted", { + txRef: ref, + }); + } + + let timeoutId: ReturnType | undefined; + let abortHandler: (() => void) | undefined; + + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject( + new ClearnetSdkError("RECEIPT_TIMEOUT", "sol: receipt timeout", { + txRef: ref, + }), + ); + }, timeoutMs); + }); + + const abortPromise = + signal === undefined + ? undefined + : new Promise((_, reject) => { + abortHandler = () => { + reject( + new ClearnetSdkError("RECEIPT_TIMEOUT", "sol: receipt aborted", { + txRef: ref, + }), + ); + }; + signal.addEventListener("abort", abortHandler, { once: true }); + }); + + try { + return await Promise.race( + abortPromise === undefined + ? [wait(), timeoutPromise] + : [wait(), timeoutPromise, abortPromise], + ); + } finally { + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } + if (signal !== undefined && abortHandler !== undefined) { + signal.removeEventListener("abort", abortHandler); + } + } +} + +async function sleep( + ms: number, + signal: AbortSignal | undefined, + txRef: TxRef, +): Promise { await new Promise((resolve, reject) => { if (signal?.aborted === true) { - reject(new ClearnetSdkError("RECEIPT_TIMEOUT", "sol: receipt aborted")); + reject( + new ClearnetSdkError("RECEIPT_TIMEOUT", "sol: receipt aborted", { + txRef, + }), + ); return; } - const timeout = setTimeout(resolve, ms); - signal?.addEventListener( - "abort", - () => { + let abortHandler: (() => void) | undefined; + let timeout: ReturnType | undefined; + const cleanup = () => { + if (timeout !== undefined) { clearTimeout(timeout); - reject(new ClearnetSdkError("RECEIPT_TIMEOUT", "sol: receipt aborted")); - }, - { once: true }, - ); + } + if (signal !== undefined && abortHandler !== undefined) { + signal.removeEventListener("abort", abortHandler); + } + }; + timeout = setTimeout(() => { + cleanup(); + resolve(); + }, ms); + if (signal !== undefined) { + abortHandler = () => { + cleanup(); + reject( + new ClearnetSdkError("RECEIPT_TIMEOUT", "sol: receipt aborted", { + txRef, + }), + ); + }; + signal.addEventListener("abort", abortHandler, { once: true }); + } }); } diff --git a/sdk/ts/src/blockchain/sol/index.ts b/sdk/ts/src/blockchain/sol/index.ts index f4abeee..1100ddc 100644 --- a/sdk/ts/src/blockchain/sol/index.ts +++ b/sdk/ts/src/blockchain/sol/index.ts @@ -1,4 +1,8 @@ -export { SolanaVaultDepositor } from "./depositor.js"; +export { + eventAuthorityPda, + SolanaVaultDepositor, + vaultPda, +} from "./depositor.js"; export { SOLANA_CUSTODY_PROGRAM_ID, SOLANA_NATIVE_ASSET, diff --git a/sdk/ts/src/blockchain/sol/types.ts b/sdk/ts/src/blockchain/sol/types.ts index a0053c0..94e4c45 100644 --- a/sdk/ts/src/blockchain/sol/types.ts +++ b/sdk/ts/src/blockchain/sol/types.ts @@ -23,6 +23,12 @@ export interface SolanaSubmitDepositInput extends SubmitDepositInput { export interface SolanaSigner { publicKey: string; + /** + * Signs and submits a @solana/web3.js v1 Transaction. + * + * Implementations must set transaction.recentBlockhash, usually from + * getLatestBlockhash, before signing. + */ signAndSend(transaction: Transaction): Promise; } diff --git a/sdk/ts/src/blockchain/sol/validation.ts b/sdk/ts/src/blockchain/sol/validation.ts index 99d502c..ac0361b 100644 --- a/sdk/ts/src/blockchain/sol/validation.ts +++ b/sdk/ts/src/blockchain/sol/validation.ts @@ -1,10 +1,11 @@ import { Buffer } from "buffer"; import bs58 from "bs58"; +import { sha256 } from "@noble/hashes/sha2.js"; import { PublicKey } from "@solana/web3.js"; import { ClearnetSdkError } from "../../core/errors.js"; -import type { Bytes32Hex, TxRef } from "../../core/types.js"; +import type { Bytes32Hex, DepositDestination, TxRef } from "../../core/types.js"; import { DEFAULT_SOLANA_COMMITMENT, SOLANA_CUSTODY_PROGRAM_ID, @@ -101,6 +102,18 @@ export function requireAmount(amount: unknown): bigint { return amount; } +export function requireDepositDestination( + destination: unknown, +): DepositDestination { + if (!destination || typeof destination !== "object") { + throw new ClearnetSdkError( + "INVALID_ADDRESS", + "destination.account must be a 20-byte hex address", + ); + } + return destination as DepositDestination; +} + export function requireClearnetAccount(account: unknown): Uint8Array { if (typeof account !== "string") { throw new ClearnetSdkError( @@ -198,6 +211,12 @@ export function requireTxRef(ref: unknown): Uint8Array { "ref.raw must decode to a 64-byte Solana signature", ); } + if (fields.hash.toLowerCase() !== bytes32Hex(sha256(signature))) { + throw new ClearnetSdkError( + "INVALID_TX_REF", + "ref.hash must match the Solana signature hash", + ); + } return signature; } diff --git a/sdk/ts/src/blockchain/xrpl/depositor.ts b/sdk/ts/src/blockchain/xrpl/depositor.ts index eb455af..a68cc2b 100644 --- a/sdk/ts/src/blockchain/xrpl/depositor.ts +++ b/sdk/ts/src/blockchain/xrpl/depositor.ts @@ -20,6 +20,7 @@ import { normalizeTxHash, requireClassicAddress, requireClearnetAccount, + requireDepositDestination, requireReference, requireRpcUrl, requireSigner, @@ -49,9 +50,15 @@ export class XrplVaultDepositor input: XrplSubmitDepositInput, options: SubmitDepositOptions = {}, ): Promise { - const account = requireClearnetAccount(input.destination.account); - const reference = requireReference(input.destination.ref); - const amount = resolveAmount(input.asset, input.amount); + const fields = + input && typeof input === "object" + ? (input as Partial) + : {}; + const submitOptions = requireSubmitDepositOptions(options); + const destination = requireDepositDestination(fields.destination); + const account = requireClearnetAccount(destination.account); + const reference = requireReference(destination.ref); + const amount = resolveAmount(fields.asset, fields.amount); const payment: Payment = { TransactionType: "Payment", Account: this.signer.classicAddress, @@ -65,7 +72,7 @@ export class XrplVaultDepositor const signed = await this.sign(prepared); const ref = normalizeTxHash(signed.hash); await this.submit(signed.txBlob, ref); - options.onSubmitted?.(ref); + submitOptions.onSubmitted?.(ref); return ref; } @@ -140,7 +147,7 @@ export class XrplVaultDepositor const engineResult = response.result.engine_result; if (engineResult !== "tesSUCCESS" && engineResult !== "terQUEUED") { throw new ClearnetSdkError( - "RPC_ERROR", + "TX_REVERTED", `xrpl: deposit rejected: ${engineResult}`, { txRef: ref }, ); @@ -174,8 +181,33 @@ function isTxnNotFound(error: unknown): boolean { if (!error || typeof error !== "object") { return false; } + if (readStringProperty(error, "error") === "txnNotFound") { + return true; + } + const data = "data" in error ? error.data : undefined; + if ( + data && + typeof data === "object" && + readStringProperty(data, "error") === "txnNotFound" + ) { + return true; + } const message = "message" in error && typeof error.message === "string" ? error.message : ""; - const data = "data" in error ? String(error.data) : ""; - return message.includes("txnNotFound") || data.includes("txnNotFound"); + return message.includes("txnNotFound"); +} + +function readStringProperty(value: object, key: string): string | undefined { + const field = (value as Record)[key]; + return typeof field === "string" ? field : undefined; +} + +function requireSubmitDepositOptions(options: unknown): SubmitDepositOptions { + if (options === null || typeof options !== "object") { + throw new ClearnetSdkError( + "RPC_ERROR", + "submit options must be an object", + ); + } + return options; } diff --git a/sdk/ts/src/blockchain/xrpl/types.ts b/sdk/ts/src/blockchain/xrpl/types.ts index 768f7b0..3a2cc9b 100644 --- a/sdk/ts/src/blockchain/xrpl/types.ts +++ b/sdk/ts/src/blockchain/xrpl/types.ts @@ -15,13 +15,6 @@ export interface XrplDepositDestination extends DepositDestination { ref?: Bytes32Hex; } -export interface XrplSubmitDepositInput - extends SubmitDepositInput { - asset: XrplAsset; - amount: XrplAmount; - destination: XrplDepositDestination; -} - export interface XrplNativeDepositInput extends SubmitDepositInput { asset: "" | "XRP"; amount: bigint; @@ -34,6 +27,10 @@ export interface XrplIssuedDepositInput extends SubmitDepositInput { destination: XrplDepositDestination; } +export type XrplSubmitDepositInput = + | XrplNativeDepositInput + | XrplIssuedDepositInput; + export interface XrplSignedTransaction { txBlob: string; hash: string; diff --git a/sdk/ts/src/blockchain/xrpl/validation.ts b/sdk/ts/src/blockchain/xrpl/validation.ts index d00235f..924741b 100644 --- a/sdk/ts/src/blockchain/xrpl/validation.ts +++ b/sdk/ts/src/blockchain/xrpl/validation.ts @@ -5,7 +5,7 @@ import { isValidClassicAddress } from "xrpl"; import { ClearnetSdkError } from "../../core/errors.js"; import type { Bytes32Hex, TxRef } from "../../core/types.js"; import { UINT64_MAX, XRPL_NATIVE_ASSET } from "./constants.js"; -import type { XrplSigner } from "./types.js"; +import type { XrplDepositDestination, XrplSigner } from "./types.js"; const BYTES32_HEX_PATTERN = /^0x[a-fA-F0-9]{64}$/; const HASH_PATTERN = /^[a-fA-F0-9]{64}$/; @@ -56,7 +56,7 @@ export function requireClassicAddress(value: unknown, field: string): string { export function requireSigner(signer: unknown): XrplSigner { if (!signer || typeof signer !== "object") { throw new ClearnetSdkError( - "INVALID_ADDRESS", + "MISSING_WALLET_ACCOUNT", "XRPL signer is required", ); } @@ -64,13 +64,25 @@ export function requireSigner(signer: unknown): XrplSigner { requireClassicAddress(candidate.classicAddress, "signer.classicAddress"); if (typeof candidate.sign !== "function") { throw new ClearnetSdkError( - "INVALID_ADDRESS", + "MISSING_WALLET_ACCOUNT", "XRPL signer.sign is required", ); } return candidate as XrplSigner; } +export function requireDepositDestination( + destination: unknown, +): XrplDepositDestination { + if (!destination || typeof destination !== "object") { + throw new ClearnetSdkError( + "INVALID_ADDRESS", + "destination.account must be a 20-byte hex address", + ); + } + return destination as XrplDepositDestination; +} + export function requireClearnetAccount(account: unknown): Uint8Array { if (typeof account !== "string") { throw new ClearnetSdkError( @@ -79,8 +91,7 @@ export function requireClearnetAccount(account: unknown): Uint8Array { ); } const trimmed = account.trim(); - const segment = trimmed.slice(trimmed.lastIndexOf("/") + 1); - const hex = segment.toLowerCase().replace(/^0x/, ""); + const hex = trimmed.toLowerCase().replace(/^0x/, ""); if (!/^[a-f0-9]+$/.test(hex) || hex.length !== 40) { throw new ClearnetSdkError( "INVALID_ADDRESS", diff --git a/sdk/ts/src/index.ts b/sdk/ts/src/index.ts index 733ddd3..ced83b5 100644 --- a/sdk/ts/src/index.ts +++ b/sdk/ts/src/index.ts @@ -15,9 +15,11 @@ export type { ClearnetSdkErrorCode } from "./core/errors.js"; export { EvmVaultDepositor } from "./blockchain/evm/depositor.js"; export { EVM_NATIVE_ASSET } from "./blockchain/evm/constants.js"; export { + eventAuthorityPda, SOLANA_CUSTODY_PROGRAM_ID, SOLANA_NATIVE_ASSET, SolanaVaultDepositor, + vaultPda, } from "./blockchain/sol/index.js"; export type { SolanaAsset, diff --git a/sdk/ts/test/blockchain/sol/depositor.integration.test.ts b/sdk/ts/test/blockchain/sol/depositor.integration.test.ts index de93eb6..61cdcc0 100644 --- a/sdk/ts/test/blockchain/sol/depositor.integration.test.ts +++ b/sdk/ts/test/blockchain/sol/depositor.integration.test.ts @@ -10,12 +10,13 @@ import bs58 from "bs58"; import { beforeAll, describe, expect, it } from "vitest"; import { - SOLANA_CUSTODY_PROGRAM_ID, SOLANA_NATIVE_ASSET, SolanaVaultDepositor, + vaultPda, } from "../../../src/index.js"; import type { SolanaSigner } from "../../../src/index.js"; import { DEPOSITED_EVENT_DISCRIMINATOR } from "../../../src/blockchain/sol/constants.js"; +import { bytes32Hex } from "../../../src/blockchain/sol/validation.js"; import { createAssociatedTokenAccountIdempotent, createMint, @@ -25,7 +26,6 @@ import { } from "./spl-test-helpers.js"; const RPC_URL = process.env.SOL_RPC_URL ?? "http://127.0.0.1:8899"; -const PROGRAM_ID = new PublicKey(SOLANA_CUSTODY_PROGRAM_ID); const ACCOUNT = "00000000000000000000000000000000000000a1"; const REFERENCE = "0x3333333333333333333333333333333333333333333333333333333333333333"; @@ -96,6 +96,7 @@ describe("SolanaVaultDepositor validator integration", () => { const vaultAta = getAssociatedTokenAddress(mint, vaultPda()); expect(vaultTokenAccount.toBase58()).toBe(vaultAta.toBase58()); const beforeBalance = await tokenBalance(connection, vaultAta); + expect(beforeBalance).toBe(0n); const ref = await depositor.submitDeposit({ asset: mint.toBase58(), @@ -158,27 +159,37 @@ async function expectDepositedEvent( ): Promise { const event = await readDepositedEvent(signature); expect(event.depositor.toBase58()).toBe(expected.depositor.toBase58()); - expect(bytesToHex(event.account)).toBe(stripHexPrefix(expected.account)); - expect(bytesToHex(event.reference)).toBe(stripHexPrefix(expected.reference)); + expect(stripHexPrefix(bytes32Hex(event.account))).toBe( + stripHexPrefix(expected.account), + ); + expect(stripHexPrefix(bytes32Hex(event.reference))).toBe( + stripHexPrefix(expected.reference), + ); expect(event.mint.toBase58()).toBe(expected.mint.toBase58()); expect(event.amount).toBe(expected.amount); } async function readDepositedEvent(signature: string): Promise { - const transaction = await connection.getTransaction(signature, { - commitment: "confirmed", - maxSupportedTransactionVersion: 0, - }); - const innerInstructions = transaction?.meta?.innerInstructions ?? []; - for (const group of innerInstructions) { - for (const instruction of group.instructions) { - const event = decodeDepositedEvent(bs58.decode(instruction.data)); - if (event !== undefined) { - return event; + const deadline = Date.now() + 30_000; + for (;;) { + const transaction = await connection.getTransaction(signature, { + commitment: "confirmed", + maxSupportedTransactionVersion: 0, + }); + const innerInstructions = transaction?.meta?.innerInstructions ?? []; + for (const group of innerInstructions) { + for (const instruction of group.instructions) { + const event = decodeDepositedEvent(bs58.decode(instruction.data)); + if (event !== undefined) { + return event; + } } } + if (Date.now() >= deadline) { + throw new Error(`Deposited event not found in ${signature}`); + } + await sleep(250); } - throw new Error(`Deposited event not found in ${signature}`); } function decodeDepositedEvent(data: Uint8Array): DepositedEvent | undefined { @@ -213,23 +224,10 @@ function findBytes(data: Uint8Array, needle: readonly number[]): number { return -1; } -function bytesToHex(bytes: Uint8Array): string { - return [...bytes] - .map((byte) => byte.toString(16).padStart(2, "0")) - .join(""); -} - function stripHexPrefix(value: string): string { return value.startsWith("0x") ? value.slice(2) : value; } -function vaultPda(): PublicKey { - return PublicKey.findProgramAddressSync( - [new TextEncoder().encode("vault")], - PROGRAM_ID, - )[0]; -} - async function waitForLamports( pubkey: PublicKey, target: bigint, diff --git a/sdk/ts/test/blockchain/sol/depositor.test.ts b/sdk/ts/test/blockchain/sol/depositor.test.ts index 7e12bb4..6989eb7 100644 --- a/sdk/ts/test/blockchain/sol/depositor.test.ts +++ b/sdk/ts/test/blockchain/sol/depositor.test.ts @@ -1,6 +1,5 @@ -import { createHash } from "node:crypto"; - import bs58 from "bs58"; +import { sha256 } from "@noble/hashes/sha2.js"; import { PublicKey, SystemProgram, @@ -11,15 +10,25 @@ import { afterEach, describe, expect, expectTypeOf, it, vi } from "vitest"; import { ClearnetSdkError, + eventAuthorityPda as sdkEventAuthorityPda, SOLANA_CUSTODY_PROGRAM_ID, SOLANA_NATIVE_ASSET, SolanaVaultDepositor, + vaultPda as sdkVaultPda, } from "../../../src/index.js"; +import { + DEPOSIT_SOL_DISCRIMINATOR, + DEPOSIT_SPL_DISCRIMINATOR, + SOLANA_ASSOCIATED_TOKEN_PROGRAM_ID, + SOLANA_TOKEN_PROGRAM_ID, +} from "../../../src/blockchain/sol/constants.js"; +import { bytes32Hex } from "../../../src/blockchain/sol/validation.js"; import type { Bytes32Hex, DepositStatus, SolanaSigner, SolanaSubmitDepositInput, + SubmitDepositOptions, TxRef, VaultDepositor, } from "../../../src/index.js"; @@ -35,12 +44,11 @@ const REFERENCE = "0x2222222222222222222222222222222222222222222222222222222222222222" as Bytes32Hex; const SIGNATURE = bs58.encode(Uint8Array.from({ length: 64 }, (_, i) => i + 1)); -const DEPOSIT_SOL_DISCRIMINATOR = [108, 81, 78, 117, 125, 155, 56, 200]; -const DEPOSIT_SPL_DISCRIMINATOR = [224, 0, 198, 175, 198, 47, 105, 204]; const SYSTEM_PROGRAM_ID = SystemProgram.programId.toBase58(); -const TOKEN_PROGRAM_ID = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"; -const ASSOCIATED_TOKEN_PROGRAM_ID = - "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL"; +const TOKEN_PROGRAM_ID = SOLANA_TOKEN_PROGRAM_ID; +const ASSOCIATED_TOKEN_PROGRAM_ID = SOLANA_ASSOCIATED_TOKEN_PROGRAM_ID; +const VAULT_PDA = sdkVaultPda(PROGRAM_ID).toBase58(); +const EVENT_AUTHORITY_PDA = sdkEventAuthorityPda(PROGRAM_ID).toBase58(); interface MockSigner extends SolanaSigner { signAndSend: ReturnType< @@ -50,6 +58,7 @@ interface MockSigner extends SolanaSigner { describe("SolanaVaultDepositor", () => { afterEach(() => { + vi.useRealTimers(); vi.unstubAllGlobals(); }); @@ -91,9 +100,9 @@ describe("SolanaVaultDepositor", () => { expect(instruction.programId.toBase58()).toBe(EXPECTED_PROGRAM_ID); expect(metas(instruction)).toEqual([ meta(DEPOSITOR, true, true), - meta(vaultPda(), false, true), + meta(VAULT_PDA, false, true), meta(SYSTEM_PROGRAM_ID, false, false), - meta(eventAuthorityPda(), false, false), + meta(EVENT_AUTHORITY_PDA, false, false), meta(PROGRAM_ID, false, false), ]); expect([...instruction.data]).toEqual([ @@ -123,11 +132,11 @@ describe("SolanaVaultDepositor", () => { meta(DEPOSITOR, true, true), meta(MINT, false, false), meta(ata(DEPOSITOR, MINT), false, true), - meta(vaultPda(), false, false), - meta(ata(vaultPda(), MINT), false, true), + meta(VAULT_PDA, false, false), + meta(ata(VAULT_PDA, MINT), false, true), meta(TOKEN_PROGRAM_ID, false, false), meta(ASSOCIATED_TOKEN_PROGRAM_ID, false, false), - meta(eventAuthorityPda(), false, false), + meta(EVENT_AUTHORITY_PDA, false, false), meta(PROGRAM_ID, false, false), ]); expect([...instruction.data]).toEqual([ @@ -144,6 +153,26 @@ describe("SolanaVaultDepositor", () => { const signer = createSigner(); const depositor = createDepositor(signer); + await expect( + depositor.submitDeposit(null as unknown as SolanaSubmitDepositInput), + ).rejects.toMatchObject({ code: "INVALID_ADDRESS" }); + await expect( + depositor.submitDeposit( + { + asset: SOLANA_NATIVE_ASSET, + amount: 1n, + destination: { account: ACCOUNT }, + }, + null as unknown as SubmitDepositOptions, + ), + ).rejects.toMatchObject({ code: "RECEIPT_TIMEOUT" }); + await expect( + depositor.submitDeposit({ + asset: SOLANA_NATIVE_ASSET, + amount: 1n, + destination: null as unknown as SolanaSubmitDepositInput["destination"], + }), + ).rejects.toMatchObject({ code: "INVALID_ADDRESS" }); await expect( depositor.submitDeposit({ asset: SOLANA_NATIVE_ASSET, @@ -165,6 +194,20 @@ describe("SolanaVaultDepositor", () => { destination: { account: ACCOUNT }, }), ).rejects.toMatchObject({ code: "INVALID_AMOUNT" }); + await expect( + depositor.submitDeposit({ + asset: SOLANA_NATIVE_ASSET, + amount: 1 as unknown as bigint, + destination: { account: ACCOUNT }, + }), + ).rejects.toMatchObject({ code: "INVALID_AMOUNT" }); + await expect( + depositor.submitDeposit({ + asset: SOLANA_NATIVE_ASSET, + amount: 1n << 64n, + destination: { account: ACCOUNT }, + }), + ).rejects.toMatchObject({ code: "INVALID_AMOUNT" }); await expect( depositor.submitDeposit({ asset: SOLANA_NATIVE_ASSET, @@ -187,6 +230,89 @@ describe("SolanaVaultDepositor", () => { programId: publicKey(33).toBase58(), }), ).toThrow(ClearnetSdkError); + expect(() => + new SolanaVaultDepositor({ rpcUrl: "", signer }), + ).toThrow(ClearnetSdkError); + expect(() => + new SolanaVaultDepositor({ + rpcUrl: RPC_URL, + signer: undefined as unknown as SolanaSigner, + }), + ).toThrow(ClearnetSdkError); + expect(() => + new SolanaVaultDepositor({ + rpcUrl: RPC_URL, + signer: { + publicKey: "not-base58", + signAndSend: async () => SIGNATURE, + }, + }), + ).toThrow(ClearnetSdkError); + expect(() => + new SolanaVaultDepositor({ + rpcUrl: RPC_URL, + signer: { publicKey: DEPOSITOR.toBase58() } as unknown as SolanaSigner, + }), + ).toThrow(ClearnetSdkError); + expect(() => + new SolanaVaultDepositor({ + rpcUrl: RPC_URL, + signer, + commitment: "recent" as never, + }), + ).toThrow(ClearnetSdkError); + expect(() => + new SolanaVaultDepositor({ + rpcUrl: RPC_URL, + signer, + receiptTimeoutMs: 0, + }), + ).toThrow(ClearnetSdkError); + }); + + it("wraps signer failures before a tx ref exists", async () => { + const cause = new Error("wallet rejected"); + stubSignatureStatus({ confirmationStatus: "finalized" }); + const signer = createSigner(); + signer.signAndSend.mockRejectedValue(cause); + const depositor = createDepositor(signer); + const onSubmitted = vi.fn(); + + await expect( + depositor.submitDeposit( + { + asset: SOLANA_NATIVE_ASSET, + amount: 1n, + destination: { account: ACCOUNT }, + }, + { onSubmitted }, + ), + ).rejects.toMatchObject({ + code: "RPC_ERROR", + cause, + txRef: undefined, + }); + expect(onSubmitted).not.toHaveBeenCalled(); + }); + + it("rejects invalid signer-returned signatures before submission callback", async () => { + stubSignatureStatus({ confirmationStatus: "finalized" }); + const signer = createSigner(); + signer.signAndSend.mockResolvedValue("not a base58 signature"); + const depositor = createDepositor(signer); + const onSubmitted = vi.fn(); + + await expect( + depositor.submitDeposit( + { + asset: SOLANA_NATIVE_ASSET, + amount: 1n, + destination: { account: ACCOUNT }, + }, + { onSubmitted }, + ), + ).rejects.toMatchObject({ code: "INVALID_TX_REF" }); + expect(onSubmitted).not.toHaveBeenCalled(); }); it("attaches txRef when a post-broadcast status lookup fails", async () => { @@ -231,11 +357,64 @@ describe("SolanaVaultDepositor", () => { }); it("attaches txRef when a submitted transaction times out", async () => { + vi.useFakeTimers(); stubSignatureStatus(null); const signer = createSigner(); const depositor = createDepositor(signer); const expectedRef = txRefForSignature(SIGNATURE); + const promise = depositor.submitDeposit( + { + asset: SOLANA_NATIVE_ASSET, + amount: 1n, + destination: { account: ACCOUNT }, + }, + { receiptTimeoutMs: 1_000 }, + ); + const assertion = expect(promise).rejects.toMatchObject({ + code: "RECEIPT_TIMEOUT", + txRef: expectedRef, + }); + await vi.advanceTimersByTimeAsync(1_000); + await assertion; + }); + + it("attaches txRef when a submitted transaction is aborted while waiting", async () => { + vi.useFakeTimers(); + stubSignatureStatus(null); + const signer = createSigner(); + const depositor = createDepositor(signer); + const controller = new AbortController(); + const expectedRef = txRefForSignature(SIGNATURE); + + const promise = depositor.submitDeposit( + { + asset: SOLANA_NATIVE_ASSET, + amount: 1n, + destination: { account: ACCOUNT }, + }, + { + signal: controller.signal, + receiptTimeoutMs: 10_000, + onSubmitted() { + setTimeout(() => controller.abort(), 1); + }, + }, + ); + const assertion = expect(promise).rejects.toMatchObject({ + code: "RECEIPT_TIMEOUT", + txRef: expectedRef, + }); + await vi.advanceTimersByTimeAsync(1); + await assertion; + }); + + it("rejects invalid wait options before signing", async () => { + const signer = createSigner(); + const depositor = createDepositor(signer); + const controller = new AbortController(); + controller.abort(); + await expect( depositor.submitDeposit( { @@ -243,12 +422,43 @@ describe("SolanaVaultDepositor", () => { amount: 1n, destination: { account: ACCOUNT }, }, - { receiptTimeoutMs: 1 }, + { receiptTimeoutMs: 0 }, ), - ).rejects.toMatchObject({ + ).rejects.toMatchObject({ code: "RECEIPT_TIMEOUT" }); + await expect( + depositor.submitDeposit( + { + asset: SOLANA_NATIVE_ASSET, + amount: 1n, + destination: { account: ACCOUNT }, + }, + { signal: controller.signal }, + ), + ).rejects.toMatchObject({ code: "RECEIPT_TIMEOUT" }); + expect(signer.signAndSend).not.toHaveBeenCalled(); + }); + + it("bounds a hung post-submit status lookup", async () => { + vi.useFakeTimers(); + vi.stubGlobal("fetch", vi.fn(() => new Promise(() => undefined))); + const signer = createSigner(); + const depositor = createDepositor(signer); + const expectedRef = txRefForSignature(SIGNATURE); + + const promise = depositor.submitDeposit( + { + asset: SOLANA_NATIVE_ASSET, + amount: 1n, + destination: { account: ACCOUNT }, + }, + { receiptTimeoutMs: 1_000 }, + ); + const assertion = expect(promise).rejects.toMatchObject({ code: "RECEIPT_TIMEOUT", txRef: expectedRef, }); + await vi.advanceTimersByTimeAsync(1_000); + await assertion; }); it("maps Solana signature statuses to the shared deposit status", async () => { @@ -296,6 +506,15 @@ describe("SolanaVaultDepositor", () => { await expect( depositor.verifyDeposit({ hash: "0x1234" as Bytes32Hex, raw: SIGNATURE }, 0), ).rejects.toMatchObject({ code: "INVALID_TX_REF" }); + await expect( + depositor.verifyDeposit( + { + hash: txRefForSignature(bs58.encode(new Uint8Array(64).fill(9))).hash, + raw: SIGNATURE, + }, + 0, + ), + ).rejects.toMatchObject({ code: "INVALID_TX_REF" }); await expect( depositor.verifyDeposit(txRefForSignature(SIGNATURE), -1), ).rejects.toMatchObject({ code: "INVALID_CONFIRMATIONS" }); @@ -305,6 +524,19 @@ describe("SolanaVaultDepositor", () => { expect(fetch).not.toHaveBeenCalled(); }); + + it("preserves txRef when verifyDeposit status lookup fails", async () => { + const rpcError = new Error("node offline"); + stubRpcFailure(rpcError); + const depositor = createDepositor(createSigner()); + const ref = txRefForSignature(SIGNATURE); + + await expect(depositor.verifyDeposit(ref, 0)).rejects.toMatchObject({ + code: "RPC_ERROR", + txRef: ref, + cause: rpcError, + }); + }); }); function createDepositor(signer: SolanaSigner): SolanaVaultDepositor { @@ -331,21 +563,6 @@ function publicKey(seed: number): PublicKey { return new PublicKey(Uint8Array.from({ length: 32 }, (_, i) => seed + i)); } -function vaultPda(): string { - return pda("vault"); -} - -function eventAuthorityPda(): string { - return pda("__event_authority"); -} - -function pda(seed: string): string { - return PublicKey.findProgramAddressSync( - [new TextEncoder().encode(seed)], - PROGRAM_ID, - )[0].toBase58(); -} - function ata(owner: PublicKey | string, mint: PublicKey): string { const ownerKey = typeof owner === "string" ? new PublicKey(owner) : owner; return PublicKey.findProgramAddressSync( @@ -389,8 +606,7 @@ function u64(value: bigint): Uint8Array { function txRefForSignature(signature: string): TxRef { const signatureBytes = bs58.decode(signature); - const hash = createHash("sha256").update(signatureBytes).digest("hex"); - return { hash: `0x${hash}`, raw: signature }; + return { hash: bytes32Hex(sha256(signatureBytes)), raw: signature }; } function stubSignatureStatus( diff --git a/sdk/ts/test/blockchain/xrpl/depositor.integration.test.ts b/sdk/ts/test/blockchain/xrpl/depositor.integration.test.ts index 8d41c84..e60b244 100644 --- a/sdk/ts/test/blockchain/xrpl/depositor.integration.test.ts +++ b/sdk/ts/test/blockchain/xrpl/depositor.integration.test.ts @@ -18,6 +18,12 @@ const GENESIS_SEED = "snoPBrXtMeMyMHUVTgbuqAfg1SUTb"; const ACCOUNT = "0x00000000000000000000000000000000000000a1"; const REFERENCE = "0x1111111111111111111111111111111111111111111111111111111111111111" as Bytes32Hex; +const UNKNOWN_TX_RAW = + "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF"; +const UNKNOWN_TX_REF = { + hash: `0x${UNKNOWN_TX_RAW.toLowerCase()}`, + raw: UNKNOWN_TX_RAW, +} satisfies TxRef; const MEMO_TYPE = "796E65742D6163636F756E74"; const ASF_DEFAULT_RIPPLE = 8; @@ -108,6 +114,16 @@ describe("XrplVaultDepositor integration", () => { }); expect(payment.Memos).toEqual(expectedMemo(ACCOUNT)); }, 90_000); + + it("maps an unknown transaction to absent", async () => { + const sdk = new XrplVaultDepositor({ + rpcUrl: XRPL_WS_URL, + vaultAddress: vault.classicAddress, + signer: signerFromWallet(depositorWallet), + }); + + await expect(sdk.verifyDeposit(UNKNOWN_TX_REF, 0)).resolves.toBe("absent"); + }, 60_000); }); function signerFromWallet(wallet: Wallet): XrplSigner { diff --git a/sdk/ts/test/blockchain/xrpl/depositor.test.ts b/sdk/ts/test/blockchain/xrpl/depositor.test.ts index 4bdd2dd..bf823af 100644 --- a/sdk/ts/test/blockchain/xrpl/depositor.test.ts +++ b/sdk/ts/test/blockchain/xrpl/depositor.test.ts @@ -23,6 +23,7 @@ import type { DepositStatus, TxRef, VaultDepositor, + XrplDepositDestination, XrplIssuedDepositInput, XrplNativeDepositInput, XrplSigner, @@ -78,9 +79,21 @@ describe("XrplVaultDepositor", () => { expectTypeOf().toMatchTypeOf< VaultDepositor >(); - expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf< + XrplNativeDepositInput | XrplIssuedDepositInput + >(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); + expectTypeOf<{ + asset: "XRP"; + amount: string; + destination: XrplDepositDestination; + }>().not.toMatchTypeOf(); + expectTypeOf<{ + asset: `USD.${string}`; + amount: bigint; + destination: XrplDepositDestination; + }>().not.toMatchTypeOf(); expectTypeOf().toEqualTypeOf<{ hash: Bytes32Hex; raw: string }>(); expectTypeOf().toEqualTypeOf< "absent" | "pending" | "confirmed" @@ -185,6 +198,26 @@ describe("XrplVaultDepositor", () => { const signer = createSigner(); const depositor = createDepositor(signer); + await expect( + depositor.submitDeposit(null as unknown as XrplSubmitDepositInput), + ).rejects.toMatchObject({ code: "INVALID_ADDRESS" }); + await expect( + depositor.submitDeposit( + { + asset: XRPL_NATIVE_ASSET, + amount: 1n, + destination: { account: ACCOUNT }, + }, + null as never, + ), + ).rejects.toMatchObject({ code: "RPC_ERROR" }); + await expect( + depositor.submitDeposit({ + asset: XRPL_NATIVE_ASSET, + amount: 1n, + destination: null as unknown as XrplDepositDestination, + }), + ).rejects.toMatchObject({ code: "INVALID_ADDRESS" }); await expect( depositor.submitDeposit({ asset: XRPL_NATIVE_ASSET, @@ -215,7 +248,7 @@ describe("XrplVaultDepositor", () => { ).rejects.toMatchObject({ code: "INVALID_AMOUNT" }); await expect( depositor.submitDeposit({ - asset: "USD", + asset: "USD" as XrplIssuedDepositInput["asset"], amount: "1", destination: { account: ACCOUNT }, }), @@ -234,6 +267,13 @@ describe("XrplVaultDepositor", () => { destination: { account: "0x1234" }, }), ).rejects.toMatchObject({ code: "INVALID_ADDRESS" }); + await expect( + depositor.submitDeposit({ + asset: XRPL_NATIVE_ASSET, + amount: 1n, + destination: { account: `yellow://local/user/${ACCOUNT}` }, + }), + ).rejects.toMatchObject({ code: "INVALID_ADDRESS" }); await expect( depositor.submitDeposit({ asset: XRPL_NATIVE_ASSET, @@ -255,7 +295,7 @@ describe("XrplVaultDepositor", () => { signer: undefined as unknown as XrplSigner, }), ).toThrowError( - expect.objectContaining({ code: "INVALID_ADDRESS" }), + expect.objectContaining({ code: "MISSING_WALLET_ACCOUNT" }), ); expect(() => new XrplVaultDepositor({ @@ -273,7 +313,7 @@ describe("XrplVaultDepositor", () => { signer: { classicAddress: DEPOSITOR_ADDRESS } as XrplSigner, }), ).toThrowError( - expect.objectContaining({ code: "INVALID_ADDRESS" }), + expect.objectContaining({ code: "MISSING_WALLET_ACCOUNT" }), ); expect(() => new XrplVaultDepositor({ @@ -314,7 +354,7 @@ describe("XrplVaultDepositor", () => { amount: 1n, destination: { account: ACCOUNT }, }), - ).rejects.toMatchObject({ code: "RPC_ERROR" }); + ).rejects.toMatchObject({ code: "TX_REVERTED", txRef: HASH_REF }); signer.sign.mockResolvedValueOnce({ txBlob: TX_BLOB, hash: "not-a-hash" }); await expect( @@ -335,7 +375,10 @@ describe("XrplVaultDepositor", () => { client.request.mockResolvedValueOnce(txResponse(false)); await expect(depositor.verifyDeposit(HASH_REF, 1n)).resolves.toBe("pending"); - client.request.mockRejectedValueOnce(new Error("txnNotFound")); + client.request.mockRejectedValueOnce({ + message: "Transaction not found.", + data: { error: "txnNotFound" }, + }); await expect(depositor.verifyDeposit(HASH_REF, 0)).resolves.toBe("absent"); const rpcError = new Error("node offline"); diff --git a/sdk/ts/test/examples/xrpl-deposit/local-signer.test.ts b/sdk/ts/test/examples/xrpl-deposit/local-signer.test.ts new file mode 100644 index 0000000..10aef9f --- /dev/null +++ b/sdk/ts/test/examples/xrpl-deposit/local-signer.test.ts @@ -0,0 +1,30 @@ +import { Wallet, hashes, type Payment } from "xrpl"; +import { describe, expect, it } from "vitest"; + +import { + createLocalXrplSigner, + LOCAL_XRPL_GENESIS_SEED, +} from "../../../examples/xrpl-deposit/src/local-signer.js"; + +describe("XRPL deposit demo local signer", () => { + it("creates a signer from a seed and signs a prepared payment", async () => { + const signer = createLocalXrplSigner(LOCAL_XRPL_GENESIS_SEED); + const wallet = Wallet.fromSeed(LOCAL_XRPL_GENESIS_SEED); + const payment: Payment = { + TransactionType: "Payment", + Account: signer.classicAddress, + Destination: wallet.classicAddress, + Amount: "1000", + Fee: "10", + Sequence: 1, + LastLedgerSequence: 10, + NetworkID: 31337, + }; + + const signed = await signer.sign(payment); + + expect(signer.classicAddress).toBe(wallet.classicAddress); + expect(signed.hash).toBe(hashes.hashSignedTx(signed.txBlob)); + expect(signed.hash).toMatch(/^[A-F0-9]{64}$/); + }); +}); From 7d4fcb7bba0674ea8aa426e2655c395258aaf247 Mon Sep 17 00:00:00 2001 From: Maharshi Mishra Date: Sun, 28 Jun 2026 16:51:18 +0530 Subject: [PATCH 3/3] fix(ts): address xrpl review comments --- sdk/ts/README.md | 33 +++-- sdk/ts/examples/xrpl-deposit/README.md | 5 + sdk/ts/examples/xrpl-deposit/src/main.ts | 82 +++++++++---- sdk/ts/src/blockchain/xrpl/depositor.ts | 54 ++++++++- sdk/ts/src/blockchain/xrpl/validation.ts | 2 +- sdk/ts/src/core/errors.ts | 1 + .../xrpl/depositor.integration.test.ts | 66 +++++----- sdk/ts/test/blockchain/xrpl/depositor.test.ts | 114 +++++++++++++++++- 8 files changed, 288 insertions(+), 69 deletions(-) diff --git a/sdk/ts/README.md b/sdk/ts/README.md index c6dc583..3e5ed7e 100644 --- a/sdk/ts/README.md +++ b/sdk/ts/README.md @@ -223,18 +223,22 @@ const depositor = new XrplVaultDepositor({ signer, }); -const ref = await depositor.submitDeposit({ - destination: { - account: "00000000000000000000000000000000000000a1", - ref: "0x3333333333333333333333333333333333333333333333333333333333333333", - }, - asset: XRPL_NATIVE_ASSET, - amount: 1_000_000n, -}); - -console.log(ref.raw); // uppercase XRPL transaction hash -console.log(ref.hash); // same bytes as 0x-prefixed hex -console.log(await depositor.verifyDeposit(ref, 0)); +try { + const ref = await depositor.submitDeposit({ + destination: { + account: "00000000000000000000000000000000000000a1", + ref: "0x3333333333333333333333333333333333333333333333333333333333333333", + }, + asset: XRPL_NATIVE_ASSET, + amount: 1_000_000n, + }); + + console.log(ref.raw); // uppercase XRPL transaction hash + console.log(ref.hash); // same bytes as 0x-prefixed hex + console.log(await depositor.verifyDeposit(ref, 0)); +} finally { + await depositor.disconnect(); +} ``` For issued currencies, pass the asset key and decimal string amount: @@ -401,6 +405,10 @@ XRPL input fields: For XRPL, `TxRef.raw` is the uppercase 64-hex transaction hash and `TxRef.hash` is the same bytes as `0x` hex. +`XrplVaultDepositor` 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. + ### `verifyDeposit(ref, minConfirmations)` Returns `Promise<"absent" | "pending" | "confirmed">`. @@ -521,6 +529,7 @@ Errors thrown by the SDK use `ClearnetSdkError` with a stable `code`. | Code | Common cause | |---|---| +| `INVALID_INPUT` | XRPL submit options are missing or have the wrong shape. | | `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, or exceeds the chain limit (`uint256` for EVM, `uint64` for Solana/XRPL native drops). | | `INVALID_CONFIRMATIONS` | `minConfirmations` is negative, fractional, or an unsafe number. | diff --git a/sdk/ts/examples/xrpl-deposit/README.md b/sdk/ts/examples/xrpl-deposit/README.md index 7849eef..857db12 100644 --- a/sdk/ts/examples/xrpl-deposit/README.md +++ b/sdk/ts/examples/xrpl-deposit/README.md @@ -130,3 +130,8 @@ The `Fund Wallet` button and `ledger_accept` call are for the repository's local standalone devnet. They are not public XRPL or Xahau testnet flows. On a public network, fund the wallet through that network's faucet or normal account funding process and remove the standalone admin assumptions. + +The page reuses the active `XrplVaultDepositor` for `Verify Last Tx` and closes +that WebSocket connection when the signer is replaced or the page is unloaded. +Changing the signer, RPC URL, or vault address means submitting again before +verifying. diff --git a/sdk/ts/examples/xrpl-deposit/src/main.ts b/sdk/ts/examples/xrpl-deposit/src/main.ts index 59e0ced..c9899d4 100644 --- a/sdk/ts/examples/xrpl-deposit/src/main.ts +++ b/sdk/ts/examples/xrpl-deposit/src/main.ts @@ -30,6 +30,7 @@ const logOutput = mustElement("log"); let signer: XrplSigner | undefined; let lastRef: TxRef | undefined; +let depositor: XrplVaultDepositor | undefined; const GEMWALLET_NETWORK_TIMEOUT_MS = 8_000; const GEMWALLET_ADDRESS_TIMEOUT_MS = 60_000; @@ -47,7 +48,7 @@ type NetworkIdentity = { }; localSignerButton.addEventListener("click", () => { - connectLocalSigner(); + void connectLocalSigner(); }); gemWalletButton.addEventListener("click", () => { @@ -67,13 +68,23 @@ verifyButton.addEventListener("click", () => { void verifyLastTx(); }); +for (const id of ["rpc-url", "vault-address"]) { + mustElement(id).addEventListener("input", () => { + void clearSubmittedDeposit().catch(console.error); + }); +} + writeLog("Use a local signer, fund it, then submit an XRPL deposit."); -function connectLocalSigner(): void { +window.addEventListener("pagehide", () => { + void disposeDepositor().catch(console.error); +}); + +async function connectLocalSigner(): Promise { setBusy(localSignerButton, true); try { const localSigner = createLocalXrplSigner(readOptional("local-seed")); - signer = localSigner; + await replaceSigner(localSigner); if (localSigner.seed !== undefined) { setInput("local-seed", localSigner.seed); } @@ -105,7 +116,7 @@ async function connectGemWallet(): Promise { if (address === undefined || address === "") { throw new Error("GemWallet did not return an address"); } - signer = new GemWalletSigner(address); + await replaceSigner(new GemWalletSigner(address)); writeLog( `Connected GemWallet ${address}\n` + "Network will be verified before signing.", @@ -119,7 +130,7 @@ async function connectGemWallet(): Promise { async function fundWallet(): Promise { if (signer === undefined) { - connectLocalSigner(); + await connectLocalSigner(); } if (signer === undefined) { return; @@ -165,13 +176,14 @@ async function fundWallet(): Promise { async function submitDeposit(): Promise { if (signer === undefined) { - connectLocalSigner(); + await connectLocalSigner(); } if (signer === undefined) { return; } setBusy(submitButton, true); + let activeDepositor: XrplVaultDepositor | undefined; try { if (signer instanceof GemWalletSigner) { await assertGemWalletMatchesApp(); @@ -179,7 +191,8 @@ async function submitDeposit(): Promise { const ref = readOptional("reference"); const maxFeeDrops = readOptional("max-fee-drops"); - const depositor = new XrplVaultDepositor({ + await clearSubmittedDeposit(); + activeDepositor = new XrplVaultDepositor({ rpcUrl: readInput("rpc-url"), vaultAddress: readInput("vault-address"), signer, @@ -187,8 +200,9 @@ async function submitDeposit(): Promise { ? {} : { maxFeeDrops: BigInt(maxFeeDrops) }), }); + depositor = activeDepositor; const asset = readInput("asset"); - lastRef = await depositor.submitDeposit( + const submittedRef = await activeDepositor.submitDeposit( isNativeAsset(asset) ? { destination: { @@ -208,34 +222,38 @@ async function submitDeposit(): Promise { }, { onSubmitted(ref) { - lastRef = ref; - verifyButton.disabled = false; - writeLog(`Submitted ${ref.raw}\nhash: ${ref.hash}`); + if (depositor === activeDepositor) { + lastRef = ref; + writeLog(`Submitted ${ref.raw}\nhash: ${ref.hash}`); + } }, }, ); await ledgerAccept(); - verifyButton.disabled = false; - writeLog(`Accepted ${lastRef.raw}\nhash: ${lastRef.hash}`); + if (depositor === activeDepositor) { + lastRef = submittedRef; + verifyButton.disabled = false; + writeLog(`Accepted ${submittedRef.raw}\nhash: ${submittedRef.hash}`); + } } catch (error) { const txRef = errorTxRef(error); - writeError(error, txRef === undefined ? undefined : `Submitted ${txRef.raw}`); + if ( + lastRef !== undefined && + activeDepositor !== undefined && + depositor === activeDepositor + ) { + verifyButton.disabled = false; + } + writeError(error, txRef === undefined ? undefined : `TxRef ${txRef.raw}`); } finally { setBusy(submitButton, false); } } async function verifyLastTx(): Promise { - if (lastRef === undefined || signer === undefined) { + if (lastRef === undefined || depositor === undefined) { return; } - const maxFeeDrops = readOptional("max-fee-drops"); - const depositor = new XrplVaultDepositor({ - rpcUrl: readInput("rpc-url"), - vaultAddress: readInput("vault-address"), - signer, - ...(maxFeeDrops === undefined ? {} : { maxFeeDrops: BigInt(maxFeeDrops) }), - }); setBusy(verifyButton, true); try { @@ -248,6 +266,26 @@ async function verifyLastTx(): Promise { } } +async function replaceSigner(nextSigner: XrplSigner): Promise { + await clearSubmittedDeposit(); + signer = nextSigner; +} + +async function clearSubmittedDeposit(): Promise { + const cleanup = disposeDepositor(); + lastRef = undefined; + verifyButton.disabled = true; + await cleanup; +} + +async function disposeDepositor(): Promise { + const current = depositor; + depositor = undefined; + if (current !== undefined) { + await current.disconnect(); + } +} + async function ledgerAccept(): Promise { const response = await fetch(readInput("admin-rpc-url"), { method: "POST", diff --git a/sdk/ts/src/blockchain/xrpl/depositor.ts b/sdk/ts/src/blockchain/xrpl/depositor.ts index a68cc2b..7803824 100644 --- a/sdk/ts/src/blockchain/xrpl/depositor.ts +++ b/sdk/ts/src/blockchain/xrpl/depositor.ts @@ -35,6 +35,7 @@ export class XrplVaultDepositor private readonly vaultAddress: string; private readonly maxFeeDrops: bigint | undefined; private readonly client: Client; + private connecting: Promise | undefined; constructor(config: XrplDepositorConfig) { this.signer = requireSigner(config.signer); @@ -81,14 +82,14 @@ export class XrplVaultDepositor minConfirmations: bigint | number, ): Promise { const normalized = requireTxRef(ref); - normalizeMinConfirmations(minConfirmations); + const minConf = normalizeMinConfirmations(minConfirmations); await this.ensureConnected(); try { const response = await this.client.request({ command: "tx", transaction: normalized.raw, }); - return response.result.validated === true ? "confirmed" : "pending"; + return xrplDepositStatus(response.result.validated === true, minConf); } catch (error) { if (isTxnNotFound(error)) { return "absent"; @@ -99,6 +100,23 @@ export class XrplVaultDepositor } } + async disconnect(): Promise { + const connecting = this.connecting; + if (connecting !== undefined) { + await connecting; + } + if (!this.client.isConnected()) { + return; + } + try { + await this.client.disconnect(); + } catch (error) { + throw new ClearnetSdkError("RPC_ERROR", "xrpl: disconnect", { + cause: error, + }); + } + } + private async autofill(payment: Payment): Promise { await this.ensureConnected(); try { @@ -167,6 +185,19 @@ export class XrplVaultDepositor if (this.client.isConnected()) { return; } + if (this.connecting !== undefined) { + await this.connecting; + return; + } + this.connecting = this.connect(); + try { + await this.connecting; + } finally { + this.connecting = undefined; + } + } + + private async connect(): Promise { try { await this.client.connect(); } catch (error) { @@ -202,12 +233,29 @@ function readStringProperty(value: object, key: string): string | undefined { return typeof field === "string" ? field : undefined; } +function xrplDepositStatus(validated: boolean, minConfirmations: bigint): DepositStatus { + // XRPL finality is binary: a transaction in a validated ledger is final. + // The shared minConfirmations argument is validated for API parity only. + void minConfirmations; + return validated ? "confirmed" : "pending"; +} + function requireSubmitDepositOptions(options: unknown): SubmitDepositOptions { if (options === null || typeof options !== "object") { throw new ClearnetSdkError( - "RPC_ERROR", + "INVALID_INPUT", "submit options must be an object", ); } + const candidate = options as Partial; + if ( + candidate.onSubmitted !== undefined && + typeof candidate.onSubmitted !== "function" + ) { + throw new ClearnetSdkError( + "INVALID_INPUT", + "submit options.onSubmitted must be a function", + ); + } return options; } diff --git a/sdk/ts/src/blockchain/xrpl/validation.ts b/sdk/ts/src/blockchain/xrpl/validation.ts index 924741b..f1060ee 100644 --- a/sdk/ts/src/blockchain/xrpl/validation.ts +++ b/sdk/ts/src/blockchain/xrpl/validation.ts @@ -77,7 +77,7 @@ export function requireDepositDestination( if (!destination || typeof destination !== "object") { throw new ClearnetSdkError( "INVALID_ADDRESS", - "destination.account must be a 20-byte hex address", + "destination is required and must be an object", ); } return destination as XrplDepositDestination; diff --git a/sdk/ts/src/core/errors.ts b/sdk/ts/src/core/errors.ts index 5d6f79b..c383764 100644 --- a/sdk/ts/src/core/errors.ts +++ b/sdk/ts/src/core/errors.ts @@ -1,6 +1,7 @@ import type { TxRef } from "./types.js"; export type ClearnetSdkErrorCode = + | "INVALID_INPUT" | "INVALID_ADDRESS" | "INVALID_AMOUNT" | "INVALID_CONFIRMATIONS" diff --git a/sdk/ts/test/blockchain/xrpl/depositor.integration.test.ts b/sdk/ts/test/blockchain/xrpl/depositor.integration.test.ts index e60b244..c2c2e3b 100644 --- a/sdk/ts/test/blockchain/xrpl/depositor.integration.test.ts +++ b/sdk/ts/test/blockchain/xrpl/depositor.integration.test.ts @@ -63,19 +63,23 @@ describe("XrplVaultDepositor integration", () => { signer: signerFromWallet(depositorWallet), }); - const ref = await sdk.submitDeposit({ - asset: XRPL_NATIVE_ASSET, - amount: 10_000_000n, - destination: { account: ACCOUNT, ref: REFERENCE }, - }); - await admin.ledgerAccept(); + try { + const ref = await sdk.submitDeposit({ + asset: XRPL_NATIVE_ASSET, + amount: 10_000_000n, + destination: { account: ACCOUNT, ref: REFERENCE }, + }); + await admin.ledgerAccept(); - await expect(sdk.verifyDeposit(ref, 0)).resolves.toBe("confirmed"); - const payment = await fetchPayment(client, ref); - expect(payment.Account).toBe(depositorWallet.classicAddress); - expect(payment.Destination).toBe(vault.classicAddress); - expect(paymentAmount(payment)).toBe("10000000"); - expect(payment.Memos).toEqual(expectedMemo(ACCOUNT, REFERENCE)); + await expect(sdk.verifyDeposit(ref, 0)).resolves.toBe("confirmed"); + const payment = await fetchPayment(client, ref); + expect(payment.Account).toBe(depositorWallet.classicAddress); + expect(payment.Destination).toBe(vault.classicAddress); + expect(paymentAmount(payment)).toBe("10000000"); + expect(payment.Memos).toEqual(expectedMemo(ACCOUNT, REFERENCE)); + } finally { + await sdk.disconnect(); + } }, 60_000); it("submits and verifies an issued-currency deposit", async () => { @@ -98,21 +102,25 @@ describe("XrplVaultDepositor integration", () => { vaultAddress: vault.classicAddress, signer: signerFromWallet(depositorWallet), }); - const ref = await sdk.submitDeposit({ - asset: `USD.${issuer.classicAddress}`, - amount: "25", - destination: { account: ACCOUNT }, - }); - await admin.ledgerAccept(); + try { + const ref = await sdk.submitDeposit({ + asset: `USD.${issuer.classicAddress}`, + amount: "25", + destination: { account: ACCOUNT }, + }); + await admin.ledgerAccept(); - await expect(sdk.verifyDeposit(ref, 0)).resolves.toBe("confirmed"); - const payment = await fetchPayment(client, ref); - expect(paymentAmount(payment)).toEqual({ - currency: "USD", - issuer: issuer.classicAddress, - value: "25", - }); - expect(payment.Memos).toEqual(expectedMemo(ACCOUNT)); + await expect(sdk.verifyDeposit(ref, 0)).resolves.toBe("confirmed"); + const payment = await fetchPayment(client, ref); + expect(paymentAmount(payment)).toEqual({ + currency: "USD", + issuer: issuer.classicAddress, + value: "25", + }); + expect(payment.Memos).toEqual(expectedMemo(ACCOUNT)); + } finally { + await sdk.disconnect(); + } }, 90_000); it("maps an unknown transaction to absent", async () => { @@ -122,7 +130,11 @@ describe("XrplVaultDepositor integration", () => { signer: signerFromWallet(depositorWallet), }); - await expect(sdk.verifyDeposit(UNKNOWN_TX_REF, 0)).resolves.toBe("absent"); + try { + await expect(sdk.verifyDeposit(UNKNOWN_TX_REF, 0)).resolves.toBe("absent"); + } finally { + await sdk.disconnect(); + } }, 60_000); }); diff --git a/sdk/ts/test/blockchain/xrpl/depositor.test.ts b/sdk/ts/test/blockchain/xrpl/depositor.test.ts index bf823af..aa8a70d 100644 --- a/sdk/ts/test/blockchain/xrpl/depositor.test.ts +++ b/sdk/ts/test/blockchain/xrpl/depositor.test.ts @@ -21,6 +21,7 @@ import { import type { Bytes32Hex, DepositStatus, + SubmitDepositOptions, TxRef, VaultDepositor, XrplDepositDestination, @@ -200,7 +201,10 @@ describe("XrplVaultDepositor", () => { await expect( depositor.submitDeposit(null as unknown as XrplSubmitDepositInput), - ).rejects.toMatchObject({ code: "INVALID_ADDRESS" }); + ).rejects.toMatchObject({ + code: "INVALID_ADDRESS", + message: "destination is required and must be an object", + }); await expect( depositor.submitDeposit( { @@ -210,14 +214,43 @@ describe("XrplVaultDepositor", () => { }, null as never, ), - ).rejects.toMatchObject({ code: "RPC_ERROR" }); + ).rejects.toMatchObject({ + code: "INVALID_INPUT", + message: "submit options must be an object", + }); + await expect( + depositor.submitDeposit( + { + asset: XRPL_NATIVE_ASSET, + amount: 1n, + destination: { account: ACCOUNT }, + }, + { onSubmitted: "bad" } as unknown as SubmitDepositOptions, + ), + ).rejects.toMatchObject({ + code: "INVALID_INPUT", + message: "submit options.onSubmitted must be a function", + }); await expect( depositor.submitDeposit({ asset: XRPL_NATIVE_ASSET, amount: 1n, destination: null as unknown as XrplDepositDestination, }), - ).rejects.toMatchObject({ code: "INVALID_ADDRESS" }); + ).rejects.toMatchObject({ + code: "INVALID_ADDRESS", + message: "destination is required and must be an object", + }); + await expect( + depositor.submitDeposit({ + asset: XRPL_NATIVE_ASSET, + amount: 1n, + destination: "bad" as unknown as XrplDepositDestination, + }), + ).rejects.toMatchObject({ + code: "INVALID_ADDRESS", + message: "destination is required and must be an object", + }); await expect( depositor.submitDeposit({ asset: XRPL_NATIVE_ASSET, @@ -370,7 +403,9 @@ describe("XrplVaultDepositor", () => { const depositor = createDepositor(createSigner()); client.request.mockResolvedValueOnce(txResponse(true)); - await expect(depositor.verifyDeposit(HASH_REF, 0)).resolves.toBe("confirmed"); + await expect(depositor.verifyDeposit(HASH_REF, 100)).resolves.toBe( + "confirmed", + ); client.request.mockResolvedValueOnce(txResponse(false)); await expect(depositor.verifyDeposit(HASH_REF, 1n)).resolves.toBe("pending"); @@ -404,6 +439,63 @@ describe("XrplVaultDepositor", () => { expect(client.request).not.toHaveBeenCalled(); }); + + it("disconnects the underlying XRPL client only when connected", async () => { + const depositor = createDepositor(createSigner()); + + client.isConnected.mockReturnValueOnce(false); + await depositor.disconnect(); + expect(client.disconnect).not.toHaveBeenCalled(); + + client.isConnected.mockReturnValueOnce(true); + await depositor.disconnect(); + expect(client.disconnect).toHaveBeenCalledOnce(); + }); + + it("wraps disconnect failures as RPC errors", async () => { + const depositor = createDepositor(createSigner()); + const cause = new Error("socket close failed"); + client.isConnected.mockReturnValueOnce(true); + client.disconnect.mockRejectedValueOnce(cause); + + await expect(depositor.disconnect()).rejects.toMatchObject({ + code: "RPC_ERROR", + message: "xrpl: disconnect", + cause, + }); + }); + + it("reconnects after disconnect for later verification", async () => { + const depositor = createDepositor(createSigner()); + + client.isConnected.mockReturnValueOnce(true); + await depositor.disconnect(); + + client.isConnected.mockReturnValueOnce(false); + await expect(depositor.verifyDeposit(HASH_REF, 0)).resolves.toBe( + "confirmed", + ); + expect(client.connect).toHaveBeenCalledOnce(); + }); + + it("disconnects after an in-flight connect settles", async () => { + const connect = deferred(); + client.connect.mockImplementationOnce(() => connect.promise); + const depositor = createDepositor(createSigner()); + + const verification = depositor.verifyDeposit(HASH_REF, 0); + await Promise.resolve(); + + const disconnect = depositor.disconnect(); + await Promise.resolve(); + expect(client.disconnect).not.toHaveBeenCalled(); + + client.isConnected.mockReturnValue(true); + connect.resolve(); + await disconnect; + await expect(verification).resolves.toBe("confirmed"); + expect(client.disconnect).toHaveBeenCalledOnce(); + }); }); function createDepositor( @@ -480,3 +572,17 @@ function txResponse(validated: boolean): TxResponse { }, } as unknown as TxResponse; } + +function deferred(): { + promise: Promise; + resolve: (value: T | PromiseLike) => void; + reject: (reason?: unknown) => void; +} { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +}