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/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/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..f26b0e7 100644 --- a/devnet/rippled.cfg +++ b/devnet/rippled.cfg @@ -23,6 +23,11 @@ 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] +31337 + [node_db] type=NuDB path=/var/lib/rippled/db/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 989f863..3e5ed7e 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: @@ -189,6 +191,74 @@ 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, +}); + +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: + +```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 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 Pass `destination.ref` to attach a 32-byte opaque sub-account reference to the @@ -206,8 +276,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 @@ -226,7 +298,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 @@ -305,6 +378,37 @@ 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, 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 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. + +`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">`. @@ -319,6 +423,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: @@ -355,7 +460,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 @@ -372,6 +496,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: @@ -391,20 +516,28 @@ 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 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 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_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. | | `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. | -| `MISSING_WALLET_ACCOUNT` | The EVM wallet account is missing/mismatched, or the Solana signer is missing. | +| `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/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/xrpl-deposit/README.md b/sdk/ts/examples/xrpl-deposit/README.md new file mode 100644 index 0000000..857db12 --- /dev/null +++ b/sdk/ts/examples/xrpl-deposit/README.md @@ -0,0 +1,137 @@ +# 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. + +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/index.html b/sdk/ts/examples/xrpl-deposit/index.html new file mode 100644 index 0000000..ee66c93 --- /dev/null +++ b/sdk/ts/examples/xrpl-deposit/index.html @@ -0,0 +1,191 @@ + + + + + + XRPL Deposit Demo + + + +
+

XRPL Deposit Demo

+
+
+ Network + + + + + +
+ +
+ Signer + +
+ +
+ 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/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 new file mode 100644 index 0000000..c9899d4 --- /dev/null +++ b/sdk/ts/examples/xrpl-deposit/src/main.ts @@ -0,0 +1,473 @@ +import { + getAddress, + getNetwork, + 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 { Client, Wallet, hashes, type SubmittableTransaction } from "xrpl"; +import { + createLocalXrplSigner, + LOCAL_XRPL_GENESIS_SEED, +} from "./local-signer.js"; + +const form = mustElement("deposit-form"); +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: XrplSigner | undefined; +let lastRef: TxRef | undefined; +let depositor: XrplVaultDepositor | undefined; + +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", () => { + void connectLocalSigner(); +}); + +gemWalletButton.addEventListener("click", () => { + void connectGemWallet(); +}); + +fundButton.addEventListener("click", () => { + void fundWallet(); +}); + +form.addEventListener("submit", (event) => { + event.preventDefault(); + void submitDeposit(); +}); + +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."); + +window.addEventListener("pagehide", () => { + void disposeDepositor().catch(console.error); +}); + +async function connectLocalSigner(): Promise { + setBusy(localSignerButton, true); + try { + const localSigner = createLocalXrplSigner(readOptional("local-seed")); + await replaceSigner(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 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"); + } + await replaceSigner(new GemWalletSigner(address)); + writeLog( + `Connected GemWallet ${address}\n` + + "Network will be verified before signing.", + ); + } catch (error) { + writeError(error); + } finally { + setBusy(gemWalletButton, false); + } +} + +async function fundWallet(): Promise { + if (signer === undefined) { + await connectLocalSigner(); + } + if (signer === undefined) { + return; + } + + 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) { + await connectLocalSigner(); + } + if (signer === undefined) { + return; + } + + setBusy(submitButton, true); + let activeDepositor: XrplVaultDepositor | undefined; + try { + if (signer instanceof GemWalletSigner) { + await assertGemWalletMatchesApp(); + } + + const ref = readOptional("reference"); + const maxFeeDrops = readOptional("max-fee-drops"); + await clearSubmittedDeposit(); + activeDepositor = new XrplVaultDepositor({ + rpcUrl: readInput("rpc-url"), + vaultAddress: readInput("vault-address"), + signer, + ...(maxFeeDrops === undefined + ? {} + : { maxFeeDrops: BigInt(maxFeeDrops) }), + }); + depositor = activeDepositor; + const asset = readInput("asset"); + const submittedRef = await activeDepositor.submitDeposit( + 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) { + if (depositor === activeDepositor) { + lastRef = ref; + writeLog(`Submitted ${ref.raw}\nhash: ${ref.hash}`); + } + }, + }, + ); + await ledgerAccept(); + if (depositor === activeDepositor) { + lastRef = submittedRef; + verifyButton.disabled = false; + writeLog(`Accepted ${submittedRef.raw}\nhash: ${submittedRef.hash}`); + } + } catch (error) { + const txRef = errorTxRef(error); + 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 || depositor === undefined) { + return; + } + + 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); + } +} + +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", + 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, + }); + 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), + }; + } +} + +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); + } + } +} + +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; +} + +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/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/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..7803824 --- /dev/null +++ b/sdk/ts/src/blockchain/xrpl/depositor.ts @@ -0,0 +1,261 @@ +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, + requireDepositDestination, + 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; + private connecting: Promise | undefined; + + 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 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, + 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); + submitOptions.onSubmitted?.(ref); + return ref; + } + + async verifyDeposit( + ref: TxRef, + minConfirmations: bigint | number, + ): Promise { + const normalized = requireTxRef(ref); + const minConf = normalizeMinConfirmations(minConfirmations); + await this.ensureConnected(); + try { + const response = await this.client.request({ + command: "tx", + transaction: normalized.raw, + }); + return xrplDepositStatus(response.result.validated === true, minConf); + } catch (error) { + if (isTxnNotFound(error)) { + return "absent"; + } + throw new ClearnetSdkError("RPC_ERROR", "xrpl: tx lookup", { + cause: error, + }); + } + } + + 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 { + 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( + "TX_REVERTED", + `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; + } + 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) { + throw new ClearnetSdkError("RPC_ERROR", "xrpl: connect", { + cause: error, + }); + } + } +} + +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 : ""; + 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 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( + "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/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..3a2cc9b --- /dev/null +++ b/sdk/ts/src/blockchain/xrpl/types.ts @@ -0,0 +1,49 @@ +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 XrplNativeDepositInput extends SubmitDepositInput { + asset: "" | "XRP"; + amount: bigint; + destination: XrplDepositDestination; +} + +export interface XrplIssuedDepositInput extends SubmitDepositInput { + asset: `${string}.${string}` | `${string}:${string}`; + amount: string; + destination: XrplDepositDestination; +} + +export type XrplSubmitDepositInput = + | XrplNativeDepositInput + | XrplIssuedDepositInput; + +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..f1060ee --- /dev/null +++ b/sdk/ts/src/blockchain/xrpl/validation.ts @@ -0,0 +1,285 @@ +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 { XrplDepositDestination, 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( + "MISSING_WALLET_ACCOUNT", + "XRPL signer is required", + ); + } + const candidate = signer as Partial; + requireClassicAddress(candidate.classicAddress, "signer.classicAddress"); + if (typeof candidate.sign !== "function") { + throw new ClearnetSdkError( + "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 is required and must be an object", + ); + } + return destination as XrplDepositDestination; +} + +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 hex = trimmed.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/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/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 4457f59..ced83b5 100644 --- a/sdk/ts/src/index.ts +++ b/sdk/ts/src/index.ts @@ -29,3 +29,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..c2c2e3b --- /dev/null +++ b/sdk/ts/test/blockchain/xrpl/depositor.integration.test.ts @@ -0,0 +1,283 @@ +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 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; + +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), + }); + + 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)); + } finally { + await sdk.disconnect(); + } + }, 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), + }); + 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)); + } finally { + await sdk.disconnect(); + } + }, 90_000); + + it("maps an unknown transaction to absent", async () => { + const sdk = new XrplVaultDepositor({ + rpcUrl: XRPL_WS_URL, + vaultAddress: vault.classicAddress, + signer: signerFromWallet(depositorWallet), + }); + + try { + await expect(sdk.verifyDeposit(UNKNOWN_TX_REF, 0)).resolves.toBe("absent"); + } finally { + await sdk.disconnect(); + } + }, 60_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..aa8a70d --- /dev/null +++ b/sdk/ts/test/blockchain/xrpl/depositor.test.ts @@ -0,0 +1,588 @@ +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, + SubmitDepositOptions, + TxRef, + VaultDepositor, + XrplDepositDestination, + 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< + 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" + >(); + 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(null as unknown as XrplSubmitDepositInput), + ).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: { account: ACCOUNT }, + }, + null as never, + ), + ).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", + 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, + 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" as XrplIssuedDepositInput["asset"], + 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: `yellow://local/user/${ACCOUNT}` }, + }), + ).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: "MISSING_WALLET_ACCOUNT" }), + ); + 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: "MISSING_WALLET_ACCOUNT" }), + ); + 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: "TX_REVERTED", txRef: HASH_REF }); + + 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, 100)).resolves.toBe( + "confirmed", + ); + + client.request.mockResolvedValueOnce(txResponse(false)); + await expect(depositor.verifyDeposit(HASH_REF, 1n)).resolves.toBe("pending"); + + 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"); + 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(); + }); + + 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( + 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; +} + +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 }; +} 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}$/); + }); +});