diff --git a/.github/workflows/test-ts.yml b/.github/workflows/test-ts.yml index 4372330..f4472b7 100644 --- a/.github/workflows/test-ts.yml +++ b/.github/workflows/test-ts.yml @@ -36,6 +36,10 @@ jobs: run: npm test working-directory: sdk/ts - - name: Build demo + - name: Build EVM demo run: npm --workspace @yellow-org/evm-deposit-demo run build working-directory: sdk/ts + + - name: Build Solana demo + run: npm --workspace @yellow-org/solana-deposit-demo run build + working-directory: sdk/ts diff --git a/Makefile b/Makefile index 222927e..d0c312b 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build lint test generate devnet devnet-evm devnet-down ts-deps integration +.PHONY: build lint test generate devnet devnet-evm devnet-sol devnet-down ts-deps integration build: go build ./... @@ -27,6 +27,10 @@ devnet-evm: docker compose -f devnet/docker-compose.yml up -d anvil go run ./devnet/wait --networks anvil +devnet-sol: + docker compose -f devnet/docker-compose.yml up -d solana + go run ./devnet/wait --networks solana + devnet-down: docker compose -f devnet/docker-compose.yml down -v @@ -34,7 +38,8 @@ 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 deposits. See devnet/README.md. +# per chain; the TS suite covers EVM and Solana 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 diff --git a/devnet/README.md b/devnet/README.md index eef03ea..721e2b4 100644 --- a/devnet/README.md +++ b/devnet/README.md @@ -7,15 +7,15 @@ withdrawal test runs the whole *k-of-n* quorum in-process — it holds N local `sign.KeySigner`s and drives `Pack → Validate → Sign → Merge → Submit → VerifyExecution` itself, so no p2p mesh is needed. -The TypeScript EVM SDK integration test lives under `sdk/ts/test` and runs -through the same `make integration` target. +The TypeScript SDK integration tests live under `sdk/ts/test` and run through +the same `make integration` target. ## Run ```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 integration +make integration # Go blockchain integrations + TS EVM and Solana integration make devnet-down ``` @@ -47,8 +47,20 @@ wallet, the XRPL genesis master). authority), deposits native SOL, then runs the quorum withdrawal. The Config PDA is a singleton, so the signer set is **fixed** across runs and only the withdrawalID is fresh — re-runs stay clean without a validator restart. The - validator image is multi-arch (no `platform:` pin — the Agave validator needs - AVX, which isn't emulable on Apple silicon). + TypeScript Solana integration test creates and funds local signers, submits + native SOL and SPL deposits, and verifies each returned transaction reference. + The validator image is multi-arch (no `platform:` pin — the Agave validator + needs AVX, which isn't emulable on Apple silicon). + +For focused local iteration: + +```sh +make devnet-evm +npm --prefix sdk/ts run test:integration:evm + +make devnet-sol +npm --prefix sdk/ts run test:integration:sol +``` ## Optional overrides @@ -59,6 +71,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` | +| `SOL_RPC_URL` | `http://127.0.0.1:8899` | ## Notes diff --git a/sdk/ts/README.md b/sdk/ts/README.md index 87f1c59..989f863 100644 --- a/sdk/ts/README.md +++ b/sdk/ts/README.md @@ -1,16 +1,17 @@ # Clearnet TypeScript SDK -TypeScript SDK for Clearnet integration. This package currently exposes -the EVM vault depositor, with support for native ETH deposits and ERC-20 -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 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. -The package is ESM-first and uses `viem` for EVM clients and primitives. +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. ## Install ```sh -npm install @yellow-org/clearnet-sdk viem +npm install @yellow-org/clearnet-sdk viem @solana/web3.js ``` For local development in this repository: @@ -20,7 +21,7 @@ cd sdk/ts npm ci ``` -## Quick Start +## EVM Quick Start Native ETH deposits use `EVM_NATIVE_ASSET`, which is the EVM zero address. Amounts must be `bigint` values in base units. @@ -123,6 +124,71 @@ before submitting the custody `deposit(...)` transaction. A successful If an ERC-20 approval fails before the deposit is submitted, `error.txRef` may refer to the approval transaction. +## Solana Deposits + +Solana deposits use `SolanaVaultDepositor`. The SDK builds the custody +instruction and delegates signing/broadcast to a caller-provided `SolanaSigner`. +The signer boundary is small so browser-wallet, Wallet Standard, and local +keypair adapters can live outside the core SDK. + +```ts +import { + SOLANA_NATIVE_ASSET, + SolanaVaultDepositor, +} from "@yellow-org/clearnet-sdk"; +import { + Connection, + Keypair, + LAMPORTS_PER_SOL, + sendAndConfirmTransaction, +} from "@solana/web3.js"; +import type { SolanaSigner } from "@yellow-org/clearnet-sdk"; + +const rpcUrl = "http://127.0.0.1:8899"; +const keypair = Keypair.generate(); +const connection = new Connection(rpcUrl, "confirmed"); + +const airdrop = await connection.requestAirdrop( + keypair.publicKey, + LAMPORTS_PER_SOL, +); +await connection.confirmTransaction(airdrop, "confirmed"); + +const signer: SolanaSigner = { + publicKey: keypair.publicKey.toBase58(), + async signAndSend(transaction) { + return sendAndConfirmTransaction(connection, transaction, [keypair], { + commitment: "confirmed", + preflightCommitment: "confirmed", + }); + }, +}; + +const depositor = new SolanaVaultDepositor({ + rpcUrl, + signer, + commitment: "confirmed", +}); + +const ref = await depositor.submitDeposit({ + destination: { + account: "00000000000000000000000000000000000000a1", + ref: "0x3333333333333333333333333333333333333333333333333333333333333333", + }, + asset: SOLANA_NATIVE_ASSET, + amount: 100_000_000n, +}); + +console.log(ref.raw); // Solana base58 signature +console.log(ref.hash); // 0x + sha256(signature bytes) +console.log(await depositor.verifyDeposit(ref, 0)); +``` + +Native asset aliases are `SOL`, `sol`, `native`, and an empty string. For SPL +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. + ## Deposit References Pass `destination.ref` to attach a 32-byte opaque sub-account reference to the @@ -139,8 +205,9 @@ const ref = await depositor.submitDeposit({ }); ``` -For EVM, the reference is passed to `Custody.deposit(...)` as `bytes32`. The SDK -does not interpret it. +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. ## Verify A Deposit @@ -157,7 +224,9 @@ const status = await depositor.verifyDeposit(ref, 1); | `absent` | The transaction is unknown or has a reverted receipt. | `minConfirmations` accepts a non-negative safe integer `number` or a non-negative -`bigint`. +`bigint`. EVM treats it as an inclusive receipt confirmation count. Solana maps +it onto the commitment ladder: `0` accepts `confirmed`; `>= 1` requires +`finalized`. ## API Reference @@ -208,6 +277,34 @@ type TxRef = { For EVM, `hash` and `raw` are both the transaction hash. +### `SolanaVaultDepositor` + +```ts +new SolanaVaultDepositor(config: SolanaDepositorConfig) +``` + +Config fields: + +| Field | Type | Notes | +|---|---|---| +| `rpcUrl` | `string` | Used for signature-status verification and commitment waits. | +| `signer` | `SolanaSigner` | Provides `publicKey` and `signAndSend(transaction)`. | +| `programId` | `string` | Optional. Must be the default custody program ID in this version. | +| `commitment` | `"processed" \| "confirmed" \| "finalized"` | Optional; defaults to `finalized`. | +| `receiptTimeoutMs` | `number` | Optional default timeout for commitment waits. | + +Solana input fields: + +| Field | Type | Notes | +|---|---|---| +| `destination.account` | `string` | 20-byte Clearnet account as hex, optional `0x`, or URI-like value whose final path segment is that hex. | +| `destination.ref` | `` `0x${string}` \| undefined `` | Optional 32-byte opaque reference. | +| `asset` | `string` | Native alias (`SOL`, `sol`, `native`, or empty string) or SPL mint public key. | +| `amount` | `bigint` | Positive base-unit amount that fits in `uint64`. | + +For Solana, `TxRef.raw` is the base58 signature and `TxRef.hash` is `0x` plus +the SHA-256 digest of the signature bytes. + ### `verifyDeposit(ref, minConfirmations)` Returns `Promise<"absent" | "pending" | "confirmed">`. @@ -221,6 +318,7 @@ npm run typecheck npm test npm run build npm --workspace @yellow-org/evm-deposit-demo run build +npm --workspace @yellow-org/solana-deposit-demo run build ``` Run the EVM integration test against local Anvil: @@ -239,7 +337,26 @@ make devnet-down The integration test deploys fresh `Custody` and `MockERC20` contracts on each run. -To run the repository integration suite, including this TS EVM integration test: +Run the Solana integration test against the local validator: + +```sh +# From the repository root: +make devnet-sol + +# From sdk/ts: +npm run test:integration:sol + +# From the repository root: +make devnet-down +``` + +The Solana devnet preloads the custody program at +`98eVpih8X9CAcgU9bzNB9V7VtkRrnFZUmqzEnsq7cfmg`. The integration test creates +and funds local signers, creates SPL token accounts needed for the test, submits +native SOL and SPL deposits, and verifies each returned transaction reference. + +To run the repository integration suite, including the TS EVM and Solana +integration tests: ```sh # From the repository root: @@ -254,9 +371,10 @@ Start the browser demo from `sdk/ts`: ```sh npm run demo:evm +npm run demo:sol ``` -The demo expects: +The EVM demo expects: - an EIP-1193 wallet, such as MetaMask - an RPC URL and chain ID that match the wallet's selected network @@ -266,19 +384,26 @@ The demo expects: `make devnet-evm` starts Anvil on `http://127.0.0.1:8545` with chain ID `31337`, but it does not predeploy `Custody` for the browser demo. +The Solana demo discovers wallets through Wallet Standard, uses +`solana:signTransaction`, and broadcasts the signed transaction through the +configured RPC URL. The selected wallet chain must be one the wallet advertises, +such as `solana:localnet` for a local validator. The local devnet preloads the +custody program, but the wallet must be funded and SPL token accounts must +already exist for SPL deposits. + ## Troubleshooting Errors thrown by the SDK use `ClearnetSdkError` with a stable `code`. | Code | Common cause | |---|---| -| `INVALID_ADDRESS` | `account`, `asset`, `custodyAddress`, or `walletAccount` is not a valid EVM address. | -| `INVALID_AMOUNT` | `amount` is not a positive `bigint` or does not fit in `uint256`. | +| `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_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 missing or is not a 32-byte EVM transaction hash. | -| `MISSING_WALLET_ACCOUNT` | The wallet account is missing or does not match `walletClient.account`. | -| `CHAIN_MISMATCH` | The public RPC or wallet chain does not match `chainId`. | +| `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. | +| `CHAIN_MISMATCH` | EVM only: the public RPC or wallet chain does not match `chainId`. | | `TX_REVERTED` | A submitted approval or deposit transaction reverted. | | `RECEIPT_TIMEOUT` | Waiting for a receipt timed out or was aborted. | | `RPC_ERROR` | The public RPC or wallet provider returned an unexpected error. | diff --git a/sdk/ts/examples/solana-deposit/index.html b/sdk/ts/examples/solana-deposit/index.html new file mode 100644 index 0000000..86c0f14 --- /dev/null +++ b/sdk/ts/examples/solana-deposit/index.html @@ -0,0 +1,210 @@ + + + + + + Solana Deposit Demo + + + +
+

Solana Deposit Demo

+
+
+ Network +
+ + + +
+ +
+ +
+ Deposit +
+ + + + +
+
+ +
+ + + +
+
+ +
+ + + diff --git a/sdk/ts/examples/solana-deposit/package.json b/sdk/ts/examples/solana-deposit/package.json new file mode 100644 index 0000000..2a088b9 --- /dev/null +++ b/sdk/ts/examples/solana-deposit/package.json @@ -0,0 +1,22 @@ +{ + "name": "@yellow-org/solana-deposit-demo", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite --host 127.0.0.1", + "build": "vite build" + }, + "dependencies": { + "@solana/wallet-standard-features": "^1.4.0", + "@solana/web3.js": "^1.98.4", + "@wallet-standard/app": "^1.1.1", + "@wallet-standard/base": "^1.1.1", + "@wallet-standard/features": "^1.1.1", + "@yellow-org/clearnet-sdk": "file:../.." + }, + "devDependencies": { + "typescript": "^5.9.0", + "vite": "^7.0.0" + } +} diff --git a/sdk/ts/examples/solana-deposit/src/main.ts b/sdk/ts/examples/solana-deposit/src/main.ts new file mode 100644 index 0000000..70fb875 --- /dev/null +++ b/sdk/ts/examples/solana-deposit/src/main.ts @@ -0,0 +1,336 @@ +import { + SOLANA_CUSTODY_PROGRAM_ID, + SolanaVaultDepositor, +} from "@yellow-org/clearnet-sdk"; +import { + SolanaSignTransaction, + type SolanaSignTransactionFeature, +} from "@solana/wallet-standard-features"; +import { Connection, PublicKey, Transaction } from "@solana/web3.js"; +import { getWallets } from "@wallet-standard/app"; +import type { + Wallet, + WalletAccount, + WalletWithFeatures, +} from "@wallet-standard/base"; +import { + StandardConnect, + type StandardConnectFeature, +} from "@wallet-standard/features"; +import type { + SolanaCommitment, + SolanaSigner, + TxRef, +} from "@yellow-org/clearnet-sdk"; + +type SolanaWalletChain = + | "solana:localnet" + | "solana:devnet" + | "solana:testnet" + | "solana:mainnet"; + +type StandardSolanaWallet = WalletWithFeatures< + StandardConnectFeature & SolanaSignTransactionFeature +>; + +const form = mustElement("deposit-form"); +const connectButton = mustElement("connect"); +const submitButton = mustElement("submit"); +const verifyButton = mustElement("verify"); +const logOutput = mustElement("log"); + +let signer: BrowserSolanaSigner | undefined; +let lastRef: TxRef | undefined; + +connectButton.addEventListener("click", () => { + void connectWallet(); +}); + +form.addEventListener("submit", (event) => { + event.preventDefault(); + void submitDeposit(); +}); + +verifyButton.addEventListener("click", () => { + void verifyLastTx(); +}); + +writeLog("Connect a Solana browser wallet to the configured RPC."); + +async function connectWallet(): Promise { + setBusy(connectButton, true); + try { + const chain = readWalletChain(); + const rpcUrl = readInput("rpc-url"); + const commitment = readCommitment(); + const wallet = requireWallet(chain); + const result = await wallet.features[StandardConnect].connect(); + const account = firstSupportedAccount(result.accounts, chain); + signer = new BrowserSolanaSigner(wallet, account, rpcUrl, chain, commitment); + const balance = await signer.balance(); + writeLog( + `Connected ${wallet.name} ${account.address}\nWallet balance: ${balance} lamports`, + ); + } catch (error) { + writeError(error); + } finally { + setBusy(connectButton, false); + } +} + +async function submitDeposit(): Promise { + if (signer === undefined) { + await connectWallet(); + } + if (signer === undefined) { + return; + } + + setBusy(submitButton, true); + try { + signer.assertMatches(readInput("rpc-url"), readWalletChain(), readCommitment()); + const ref = readOptional("reference"); + const depositor = new SolanaVaultDepositor({ + rpcUrl: signer.rpcUrl, + signer, + programId: readInput("program-id"), + commitment: signer.commitment, + }); + + lastRef = await depositor.submitDeposit( + { + destination: { + account: readInput("account"), + ...(ref === undefined ? {} : { ref: ref as `0x${string}` }), + }, + asset: readInput("asset"), + amount: BigInt(readInput("amount")), + }, + { + onSubmitted(ref) { + lastRef = ref; + verifyButton.disabled = false; + writeLog(`Submitted ${ref.raw}\nhash: ${ref.hash}`); + }, + }, + ); + verifyButton.disabled = false; + writeLog(`Confirmed ${lastRef.raw}\nhash: ${lastRef.hash}`); + } catch (error) { + const txRef = errorTxRef(error); + writeError(error, txRef === undefined ? undefined : `Submitted ${txRef.raw}`); + } finally { + setBusy(submitButton, false); + } +} + +async function verifyLastTx(): Promise { + if (lastRef === undefined || signer === undefined) { + return; + } + + setBusy(verifyButton, true); + try { + signer.assertMatches(readInput("rpc-url"), readWalletChain(), readCommitment()); + const depositor = new SolanaVaultDepositor({ + rpcUrl: signer.rpcUrl, + signer, + programId: readInput("program-id"), + commitment: signer.commitment, + }); + + const status = await depositor.verifyDeposit(lastRef, 0); + writeLog(`Verify ${lastRef.raw}\nstatus: ${status}`); + } catch (error) { + writeError(error); + } finally { + setBusy(verifyButton, false); + } +} + +class BrowserSolanaSigner implements SolanaSigner { + constructor( + private readonly wallet: StandardSolanaWallet, + private readonly account: WalletAccount, + readonly rpcUrl: string, + private readonly chain: SolanaWalletChain, + readonly commitment: SolanaCommitment, + ) {} + + get publicKey(): string { + return this.account.address; + } + + async balance(): Promise { + return await this.connection().getBalance(new PublicKey(this.publicKey)); + } + + assertMatches( + rpcUrl: string, + chain: SolanaWalletChain, + commitment: SolanaCommitment, + ): void { + if ( + rpcUrl !== this.rpcUrl || + chain !== this.chain || + commitment !== this.commitment + ) { + throw new Error("network settings changed after wallet connection; reconnect wallet"); + } + } + + async signAndSend(transaction: Transaction): Promise { + const latest = await this.connection().getLatestBlockhash(this.commitment); + transaction.recentBlockhash = latest.blockhash; + transaction.feePayer ??= new PublicKey(this.publicKey); + const [result] = await this.wallet.features[SolanaSignTransaction].signTransaction({ + account: this.account, + chain: this.chain, + transaction: transaction.serialize({ + requireAllSignatures: false, + verifySignatures: false, + }), + options: { + preflightCommitment: this.commitment, + }, + }); + if (result?.signedTransaction === undefined) { + throw new Error("wallet did not return a signed transaction"); + } + return await this.connection().sendRawTransaction(result.signedTransaction, { + preflightCommitment: this.commitment, + }); + } + + private connection(): Connection { + return new Connection(this.rpcUrl, this.commitment); + } +} + +function readCommitment(): SolanaCommitment { + const value = readInput("commitment"); + if (value !== "confirmed" && value !== "finalized") { + throw new Error("commitment must be confirmed or finalized"); + } + return value; +} + +function readWalletChain(): SolanaWalletChain { + const value = readInput("wallet-chain"); + if ( + value !== "solana:localnet" && + value !== "solana:devnet" && + value !== "solana:testnet" && + value !== "solana:mainnet" + ) { + throw new Error("wallet chain must be a supported Solana chain"); + } + return value; +} + +function requireWallet(chain: SolanaWalletChain): StandardSolanaWallet { + const wallet = getWallets() + .get() + .find((wallet) => supportsRequiredFeatures(wallet, chain)); + if (wallet === undefined) { + throw new Error( + `No Wallet Standard Solana wallet found for ${chain}`, + ); + } + return wallet; +} + +function supportsRequiredFeatures( + wallet: Wallet, + chain: SolanaWalletChain, +): wallet is StandardSolanaWallet { + return ( + wallet.chains.includes(chain) && + StandardConnect in wallet.features && + SolanaSignTransaction in wallet.features + ); +} + +function firstSupportedAccount( + accounts: readonly WalletAccount[], + chain: SolanaWalletChain, +): WalletAccount { + const account = accounts.find( + (account) => + account.chains.includes(chain) && + account.features.includes(SolanaSignTransaction), + ); + if (account === undefined) { + throw new Error(`wallet did not return an account for ${chain}`); + } + return account; +} + +function errorTxRef(error: unknown): TxRef | undefined { + if (error && typeof error === "object" && "txRef" in error) { + return (error as { txRef?: TxRef }).txRef; + } + return undefined; +} + +function writeError(error: unknown, prefix?: string): void { + const code = errorCode(error); + const codeText = code === undefined ? "" : ` [${String(code)}]`; + writeLog( + [prefix, `${codeText} ${errorMessage(error)}`.trim()] + .filter(Boolean) + .join("\n"), + ); +} + +function errorCode(error: unknown): number | string | undefined { + if (error && typeof error === "object" && "code" in error) { + const code = (error as { code?: unknown }).code; + if (typeof code === "number" || typeof code === "string") { + return code; + } + } + return undefined; +} + +function errorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + if (error && typeof error === "object" && "message" in error) { + const message = (error as { message?: unknown }).message; + if (typeof message === "string") { + return message; + } + } + return String(error); +} + +function readInput(id: string): string { + return mustElement(id).value.trim(); +} + +function readOptional(id: string): string | undefined { + const value = readInput(id); + return value === "" ? undefined : value; +} + +function setBusy(button: HTMLButtonElement, busy: boolean): void { + button.disabled = busy; +} + +function writeLog(message: string): void { + logOutput.value = message; +} + +function mustElement(id: string): T { + const element = document.getElementById(id); + if (element === null) { + throw new Error(`missing #${id}`); + } + return element as T; +} + +if (readInput("program-id") === "") { + mustElement("program-id").value = SOLANA_CUSTODY_PROGRAM_ID; +} diff --git a/sdk/ts/examples/solana-deposit/tsconfig.json b/sdk/ts/examples/solana-deposit/tsconfig.json new file mode 100644 index 0000000..b5062d4 --- /dev/null +++ b/sdk/ts/examples/solana-deposit/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.test.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["src/**/*.ts"] +} diff --git a/sdk/ts/package-lock.json b/sdk/ts/package-lock.json index c4533de..3d3c90c 100644 --- a/sdk/ts/package-lock.json +++ b/sdk/ts/package-lock.json @@ -12,6 +12,10 @@ "examples/*" ], "dependencies": { + "@noble/hashes": "^2.2.0", + "@solana/web3.js": "^1.98.4", + "bs58": "^6.0.0", + "buffer": "^6.0.3", "viem": "^2.39.0" }, "devDependencies": { @@ -33,12 +37,37 @@ "vite": "^7.0.0" } }, + "examples/solana-deposit": { + "name": "@yellow-org/solana-deposit-demo", + "version": "0.0.0", + "dependencies": { + "@solana/wallet-standard-features": "^1.4.0", + "@solana/web3.js": "^1.98.4", + "@wallet-standard/app": "^1.1.1", + "@wallet-standard/base": "^1.1.1", + "@wallet-standard/features": "^1.1.1", + "@yellow-org/clearnet-sdk": "file:../.." + }, + "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", "integrity": "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==", "license": "MIT" }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", @@ -515,7 +544,7 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/@noble/hashes": { + "node_modules/@noble/curves/node_modules/@noble/hashes": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", @@ -527,6 +556,18 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.62.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.0.tgz", @@ -939,6 +980,18 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@scure/bip32/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@scure/bip39": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz", @@ -952,6 +1005,146 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@scure/bip39/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@solana/buffer-layout": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@solana/buffer-layout/-/buffer-layout-4.0.1.tgz", + "integrity": "sha512-E1ImOIAD1tBZFRdjeM4/pzTiTApC0AOBGwyAMS4fwIodCWArzJ3DWdoh8cKxeFM2fElkxBh2Aqts1BPC373rHA==", + "license": "MIT", + "dependencies": { + "buffer": "~6.0.3" + }, + "engines": { + "node": ">=5.10" + } + }, + "node_modules/@solana/wallet-standard-features": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@solana/wallet-standard-features/-/wallet-standard-features-1.4.0.tgz", + "integrity": "sha512-f0tAdqwM2aL6CiFbIgt9h5zKFp+mgY/iNGwoxPMTj9VSTeQj7d1GGSmWhZw0XWoZ4N/1tnKTKmYFq+Dyq08jRw==", + "license": "Apache-2.0", + "dependencies": { + "@wallet-standard/base": "^1.1.0", + "@wallet-standard/features": "^1.1.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@solana/web3.js": { + "version": "1.98.4", + "resolved": "https://registry.npmjs.org/@solana/web3.js/-/web3.js-1.98.4.tgz", + "integrity": "sha512-vv9lfnvjUsRiq//+j5pBdXig0IQdtzA0BRZ3bXEP4KaIyF1CcaydWqgyzQgfZMNIsWNWmG+AUHwPy4AHOD6gpw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.0", + "@noble/curves": "^1.4.2", + "@noble/hashes": "^1.4.0", + "@solana/buffer-layout": "^4.0.1", + "@solana/codecs-numbers": "^2.1.0", + "agentkeepalive": "^4.5.0", + "bn.js": "^5.2.1", + "borsh": "^0.7.0", + "bs58": "^4.0.1", + "buffer": "6.0.3", + "fast-stable-stringify": "^1.0.0", + "jayson": "^4.1.1", + "node-fetch": "^2.7.0", + "rpc-websockets": "^9.0.2", + "superstruct": "^2.0.2" + } + }, + "node_modules/@solana/web3.js/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@solana/web3.js/node_modules/@solana/codecs-core": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@solana/codecs-core/-/codecs-core-2.3.0.tgz", + "integrity": "sha512-oG+VZzN6YhBHIoSKgS5ESM9VIGzhWjEHEGNPSibiDTxFhsFWxNaz8LbMDPjBUE69r9wmdGLkrQ+wVPbnJcZPvw==", + "license": "MIT", + "dependencies": { + "@solana/errors": "2.3.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.3.3" + } + }, + "node_modules/@solana/web3.js/node_modules/@solana/codecs-numbers": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@solana/codecs-numbers/-/codecs-numbers-2.3.0.tgz", + "integrity": "sha512-jFvvwKJKffvG7Iz9dmN51OGB7JBcy2CJ6Xf3NqD/VP90xak66m/Lg48T01u5IQ/hc15mChVHiBm+HHuOFDUrQg==", + "license": "MIT", + "dependencies": { + "@solana/codecs-core": "2.3.0", + "@solana/errors": "2.3.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.3.3" + } + }, + "node_modules/@solana/web3.js/node_modules/@solana/errors": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@solana/errors/-/errors-2.3.0.tgz", + "integrity": "sha512-66RI9MAbwYV0UtP7kGcTBVLxJgUxoZGm8Fbc0ah+lGiAw17Gugco6+9GrJCV83VyF2mDWyYnYM9qdI3yjgpnaQ==", + "license": "MIT", + "dependencies": { + "chalk": "^5.4.1", + "commander": "^14.0.0" + }, + "bin": { + "errors": "bin/cli.mjs" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.3.3" + } + }, + "node_modules/@solana/web3.js/node_modules/base-x": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/base-x/-/base-x-3.0.11.tgz", + "integrity": "sha512-xz7wQ8xDhdyP7tQxwdteLYeFfS68tSMNCZ/Y37WJ4bhGfKPpqEIlmIyueQHqOyoPhE6xNUqjzRr8ra0eF9VRvA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/@solana/web3.js/node_modules/bs58": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/bs58/-/bs58-4.0.1.tgz", + "integrity": "sha512-Ok3Wdf5vOIlBrgCvTq96gBkJw+JUEzdBgyaza5HLtPm7yTHkjRy8+JzNyHF7BHa0bNWOQIp3m5YF0nnFcOIKLw==", + "license": "MIT", + "dependencies": { + "base-x": "^3.0.2" + } + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -959,6 +1152,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -970,6 +1172,15 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -988,12 +1199,26 @@ "version": "24.13.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.18.0" } }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "7.4.7", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-7.4.7.tgz", + "integrity": "sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@vitest/expect": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", @@ -1107,6 +1332,39 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@wallet-standard/app": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@wallet-standard/app/-/app-1.1.1.tgz", + "integrity": "sha512-WDGwoByhP5gwHH01r5EaLgQdLVkACPCdOMQhmhn8rsm10h/siSgTorShzBxrn0ExSPof+Lu+C3TfgqBrPa1xoQ==", + "license": "Apache-2.0", + "dependencies": { + "@wallet-standard/base": "^1.1.1" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@wallet-standard/base": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@wallet-standard/base/-/base-1.1.1.tgz", + "integrity": "sha512-gggIHTtxicF9XFMQ12DkfS6NAG92Ak795JeSA7f2whAQ6Y3AkMWWuCMxSZXG2NIPN42kEaZSNVjqMsJRaJRxMQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=22" + } + }, + "node_modules/@wallet-standard/features": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@wallet-standard/features/-/features-1.1.1.tgz", + "integrity": "sha512-aCWYmVeSCGViyEU5k7GMoW8zxE4Gs+C1s1Pp2XLesvSNlnZ4PMES9HUnTB3hl0b3RVj7C61yze3IWyrncqg4MA==", + "license": "Apache-2.0", + "dependencies": { + "@wallet-standard/base": "^1.1.1" + }, + "engines": { + "node": ">=22" + } + }, "node_modules/@yellow-org/clearnet-sdk": { "resolved": "", "link": true @@ -1115,6 +1373,10 @@ "resolved": "examples/evm-deposit", "link": true }, + "node_modules/@yellow-org/solana-deposit-demo": { + "resolved": "examples/solana-deposit", + "link": true + }, "node_modules/abitype": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.2.3.tgz", @@ -1136,6 +1398,18 @@ } } }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "license": "MIT", + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1146,6 +1420,114 @@ "node": ">=12" } }, + "node_modules/base-x": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/base-x/-/base-x-5.0.1.tgz", + "integrity": "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bn.js": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.3.tgz", + "integrity": "sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w==", + "license": "MIT" + }, + "node_modules/borsh": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/borsh/-/borsh-0.7.0.tgz", + "integrity": "sha512-CLCsZGIBCFnPtkNnieW/a8wmreDmfUtjU2m9yHrzPXIlNbqVs0AQrSatSG6vdNYUqdc83tkQi2eHfF98ubzQLA==", + "license": "Apache-2.0", + "dependencies": { + "bn.js": "^5.2.0", + "bs58": "^4.0.0", + "text-encoding-utf-8": "^1.0.2" + } + }, + "node_modules/borsh/node_modules/base-x": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/base-x/-/base-x-3.0.11.tgz", + "integrity": "sha512-xz7wQ8xDhdyP7tQxwdteLYeFfS68tSMNCZ/Y37WJ4bhGfKPpqEIlmIyueQHqOyoPhE6xNUqjzRr8ra0eF9VRvA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/borsh/node_modules/bs58": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/bs58/-/bs58-4.0.1.tgz", + "integrity": "sha512-Ok3Wdf5vOIlBrgCvTq96gBkJw+JUEzdBgyaza5HLtPm7yTHkjRy8+JzNyHF7BHa0bNWOQIp3m5YF0nnFcOIKLw==", + "license": "MIT", + "dependencies": { + "base-x": "^3.0.2" + } + }, + "node_modules/bs58": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bs58/-/bs58-6.0.0.tgz", + "integrity": "sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==", + "license": "MIT", + "dependencies": { + "base-x": "^5.0.0" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/bufferutil": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.1.0.tgz", + "integrity": "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -1156,6 +1538,27 @@ "node": ">=18" } }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/commander": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.2.tgz", + "integrity": "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1163,6 +1566,18 @@ "dev": true, "license": "MIT" }, + "node_modules/delay": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/delay/-/delay-5.0.0.tgz", + "integrity": "sha512-ReEBKkIfe4ya47wlPYf/gu5ib6yUG0/Aez0JQZQz94kiWtRQvZIQbTiehsnwHvLSWJnQdhVeqYue7Id1dKr0qw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/es-module-lexer": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", @@ -1170,6 +1585,21 @@ "dev": true, "license": "MIT" }, + "node_modules/es6-promise": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz", + "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==", + "license": "MIT" + }, + "node_modules/es6-promisify": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/es6-promisify/-/es6-promisify-5.0.0.tgz", + "integrity": "sha512-C+d6UdsYDk0lMebHNR4S2NybQMMngAOnOwYBQjTOiv0MkoJMP0Myw2mgpDLBcpfCmRLxyFqYhS/CfOENq4SJhQ==", + "license": "MIT", + "dependencies": { + "es6-promise": "^4.0.3" + } + }, "node_modules/esbuild": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", @@ -1238,6 +1668,20 @@ "node": ">=12.0.0" } }, + "node_modules/eyes": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/eyes/-/eyes-0.1.8.tgz", + "integrity": "sha512-GipyPsXO1anza0AOZdy69Im7hGFCNB7Y/NGjDlZGJ3GJJLtwNSb2vrzYrTYJRrRloVx7pl+bhUaTB8yiccPvFQ==", + "engines": { + "node": "> 0.1.90" + } + }, + "node_modules/fast-stable-stringify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fast-stable-stringify/-/fast-stable-stringify-1.0.0.tgz", + "integrity": "sha512-wpYMUmFu5f00Sm0cj2pfivpmawLZ0NKdviQ4w9zJeR8JVtOpOxHmLaJuj0vxvGqMJQWyP/COUkF75/57OKyRag==", + "license": "MIT" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1271,6 +1715,44 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.0.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/isomorphic-ws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-4.0.1.tgz", + "integrity": "sha512-BhBvN2MBpWTaSHdWRb/bwdZJ1WaehQ2L1KngkCkfLUGF0mAWAT1sQUQacEmQ0jXkFw/czDXPNQSL5u2/Krsz1w==", + "license": "MIT", + "peerDependencies": { + "ws": "*" + } + }, "node_modules/isows": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/isows/-/isows-1.0.7.tgz", @@ -1286,6 +1768,50 @@ "ws": "*" } }, + "node_modules/jayson": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/jayson/-/jayson-4.3.0.tgz", + "integrity": "sha512-AauzHcUcqs8OBnCHOkJY280VaTiCm57AbuO7lqzcw7JapGj50BisE3xhksye4zlTSR1+1tAz67wLTl8tEH1obQ==", + "license": "MIT", + "dependencies": { + "@types/connect": "^3.4.33", + "@types/node": "^12.12.54", + "@types/ws": "^7.4.4", + "commander": "^2.20.3", + "delay": "^5.0.0", + "es6-promisify": "^5.0.0", + "eyes": "^0.1.8", + "isomorphic-ws": "^4.0.1", + "json-stringify-safe": "^5.0.1", + "stream-json": "^1.9.1", + "uuid": "^8.3.2", + "ws": "^7.5.10" + }, + "bin": { + "jayson": "bin/jayson.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jayson/node_modules/@types/node": { + "version": "12.20.55", + "resolved": "https://registry.npmjs.org/@types/node/-/node-12.20.55.tgz", + "integrity": "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==", + "license": "MIT" + }, + "node_modules/jayson/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "license": "ISC" + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1296,6 +1822,12 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.12", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", @@ -1315,6 +1847,38 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "optional": true, + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/obug": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", @@ -1359,6 +1923,18 @@ } } }, + "node_modules/ox/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -1460,6 +2036,71 @@ "fsevents": "~2.3.2" } }, + "node_modules/rpc-websockets": { + "version": "9.3.9", + "resolved": "https://registry.npmjs.org/rpc-websockets/-/rpc-websockets-9.3.9.tgz", + "integrity": "sha512-2iQDaTB4g5fDB2ihrTFSJSibCEuxaRi1q7qTW7ZO9/M5/TC+ToHA4D9/ffNLEbAoHNNrcdeP05oATNk44SKZXA==", + "license": "LGPL-3.0-only", + "dependencies": { + "@swc/helpers": "^0.5.11", + "@types/uuid": "^10.0.0", + "@types/ws": "^8.2.2", + "buffer": "^6.0.3", + "eventemitter3": "^5.0.1", + "uuid": "^14.0.0", + "ws": "^8.5.0" + }, + "funding": { + "type": "paypal", + "url": "https://paypal.me/kozjak" + }, + "optionalDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^6.0.0" + } + }, + "node_modules/rpc-websockets/node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/rpc-websockets/node_modules/uuid": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz", + "integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -1491,6 +2132,35 @@ "dev": true, "license": "MIT" }, + "node_modules/stream-chain": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/stream-chain/-/stream-chain-2.2.5.tgz", + "integrity": "sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA==", + "license": "BSD-3-Clause" + }, + "node_modules/stream-json": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/stream-json/-/stream-json-1.9.1.tgz", + "integrity": "sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw==", + "license": "BSD-3-Clause", + "dependencies": { + "stream-chain": "^2.2.5" + } + }, + "node_modules/superstruct": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/superstruct/-/superstruct-2.0.2.tgz", + "integrity": "sha512-uV+TFRZdXsqXTL2pRvujROjdZQ4RAlBUS5BTh9IGm+jTqQntYThciG/qu57Gs69yjnVUSqdxF9YLmSnpupBW9A==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/text-encoding-utf-8": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/text-encoding-utf-8/-/text-encoding-utf-8-1.0.2.tgz", + "integrity": "sha512-8bw4MY9WjdsD2aMtO0OzOCY3pXGYNx2d2FfHRVUKkiCPDWjKuOlhLVASS+pD7VkLTVjW268LYJHwsnPFlBpbAg==" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -1535,11 +2205,22 @@ "node": ">=14.0.0" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -1553,9 +2234,35 @@ "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "dev": true, "license": "MIT" }, + "node_modules/utf-8-validate": { + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-6.0.6.tgz", + "integrity": "sha512-q3l3P9UtEEiAHcsgsqTgf9PPjctrDWoIXW3NpOHFdRDbLvu4DLIcxHangJ4RLrWkBcKjmcs/6NkerI8T/rE4LA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, + "node_modules/uuid": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/viem": { "version": "2.52.2", "resolved": "https://registry.npmjs.org/viem/-/viem-2.52.2.tgz", @@ -1586,6 +2293,18 @@ } } }, + "node_modules/viem/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/vite": { "version": "7.3.5", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz", @@ -1751,6 +2470,22 @@ } } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", diff --git a/sdk/ts/package.json b/sdk/ts/package.json index e355521..35e1b2f 100644 --- a/sdk/ts/package.json +++ b/sdk/ts/package.json @@ -25,16 +25,27 @@ "build": "tsc -p tsconfig.json", "prepack": "npm run build", "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json --noEmit", - "test": "vitest run test/blockchain/evm/depositor.test.ts", + "test": "npm run test:evm && npm run test:sol", + "test:evm": "vitest run test/blockchain/evm/depositor.test.ts", + "test:sol": "vitest run test/blockchain/sol/depositor.test.ts", "test:integration:evm": "vitest run test/blockchain/evm/depositor.integration.test.ts", - "demo:evm": "npm --workspace @yellow-org/evm-deposit-demo run dev" + "test:integration:sol": "vitest run test/blockchain/sol/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" }, "dependencies": { + "@noble/hashes": "^2.2.0", + "@solana/web3.js": "^1.98.4", + "bs58": "^6.0.0", + "buffer": "^6.0.3", "viem": "^2.39.0" }, "overrides": { "esbuild": "^0.28.1", - "ws": "^8.21.0" + "ws": "^8.21.0", + "jayson": { + "uuid": "11.1.1" + } }, "devDependencies": { "@types/node": "^24.0.0", diff --git a/sdk/ts/src/blockchain/sol/constants.ts b/sdk/ts/src/blockchain/sol/constants.ts new file mode 100644 index 0000000..59bf201 --- /dev/null +++ b/sdk/ts/src/blockchain/sol/constants.ts @@ -0,0 +1,31 @@ +export const SOLANA_CUSTODY_PROGRAM_ID = + "98eVpih8X9CAcgU9bzNB9V7VtkRrnFZUmqzEnsq7cfmg"; + +export const SOLANA_NATIVE_ASSET = "SOL"; + +export const SOLANA_SYSTEM_PROGRAM_ID = + "11111111111111111111111111111111"; + +export const SOLANA_TOKEN_PROGRAM_ID = + "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"; + +export const SOLANA_ASSOCIATED_TOKEN_PROGRAM_ID = + "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL"; + +export const DEFAULT_SOLANA_COMMITMENT = "finalized"; + +export const DEFAULT_RECEIPT_TIMEOUT_MS = 60_000; + +export const POLL_INTERVAL_MS = 250; + +export const DEPOSIT_SOL_DISCRIMINATOR = [ + 108, 81, 78, 117, 125, 155, 56, 200, +] as const; + +export const DEPOSIT_SPL_DISCRIMINATOR = [ + 224, 0, 198, 175, 198, 47, 105, 204, +] as const; + +export const DEPOSITED_EVENT_DISCRIMINATOR = [ + 111, 141, 26, 45, 161, 35, 100, 57, +] as const; diff --git a/sdk/ts/src/blockchain/sol/depositor.ts b/sdk/ts/src/blockchain/sol/depositor.ts new file mode 100644 index 0000000..f726506 --- /dev/null +++ b/sdk/ts/src/blockchain/sol/depositor.ts @@ -0,0 +1,477 @@ +import bs58 from "bs58"; +import { sha256 } from "@noble/hashes/sha2.js"; +import { + Connection, + PublicKey, + SystemProgram, + Transaction, + TransactionInstruction, +} from "@solana/web3.js"; + +import { ClearnetSdkError } from "../../core/errors.js"; +import type { + DepositStatus, + SubmitDepositOptions, + TxRef, + VaultDepositor, +} from "../../core/types.js"; +import { + DEFAULT_RECEIPT_TIMEOUT_MS, + DEPOSIT_SOL_DISCRIMINATOR, + DEPOSIT_SPL_DISCRIMINATOR, + POLL_INTERVAL_MS, + SOLANA_ASSOCIATED_TOKEN_PROGRAM_ID, + SOLANA_CUSTODY_PROGRAM_ID, + SOLANA_TOKEN_PROGRAM_ID, +} from "./constants.js"; +import { encodeDepositData } from "./encoding.js"; +import type { + SolanaCommitment, + SolanaDepositorConfig, + SolanaSubmitDepositInput, + SolanaSigner, +} from "./types.js"; +import { + bytes32Hex, + normalizeCommitment, + normalizeMinConfirmations, + publicKeyFromString, + requireAmount, + requireClearnetAccount, + requireDepositDestination, + requireProgramId, + requireReceiptTimeout, + requireReference, + requireRpcUrl, + requireSigner, + requireTxRef, + resolveMint, +} from "./validation.js"; + +type SignatureStatusValue = Awaited< + ReturnType +>["value"][number]; + +const SOLANA_CUSTODY_PUBLIC_KEY = new PublicKey(SOLANA_CUSTODY_PROGRAM_ID); +const SOLANA_TOKEN_PROGRAM_PUBLIC_KEY = new PublicKey(SOLANA_TOKEN_PROGRAM_ID); +const SOLANA_ASSOCIATED_TOKEN_PROGRAM_PUBLIC_KEY = new PublicKey( + SOLANA_ASSOCIATED_TOKEN_PROGRAM_ID, +); +const VAULT_SEED = new TextEncoder().encode("vault"); +const EVENT_AUTHORITY_SEED = new TextEncoder().encode("__event_authority"); + +export class SolanaVaultDepositor + implements VaultDepositor +{ + private readonly signer: SolanaSigner; + private readonly depositor: PublicKey; + private readonly programId: PublicKey; + private readonly commitment: SolanaCommitment; + private readonly receiptTimeoutMs: number; + private readonly connection: Connection; + private readonly vault: PublicKey; + private readonly eventAuthority: PublicKey; + + constructor(config: SolanaDepositorConfig) { + const rpcUrl = requireRpcUrl(config.rpcUrl); + this.signer = requireSigner(config.signer); + this.depositor = publicKeyFromString(this.signer.publicKey, "signer.publicKey"); + this.programId = requireProgramId(config.programId); + this.vault = vaultPda(this.programId); + this.eventAuthority = eventAuthorityPda(this.programId); + this.commitment = normalizeCommitment(config.commitment); + this.receiptTimeoutMs = + config.receiptTimeoutMs === undefined + ? DEFAULT_RECEIPT_TIMEOUT_MS + : requireReceiptTimeout(config.receiptTimeoutMs); + this.connection = new Connection(rpcUrl, { + commitment: this.commitment, + fetch: (input, init) => globalThis.fetch(input, init), + }); + } + + async submitDeposit( + input: SolanaSubmitDepositInput, + options: SubmitDepositOptions = {}, + ): Promise { + const waitOptions = requireSubmitDepositOptions(options); + const fields = + input && typeof input === "object" + ? (input as Partial) + : {}; + const destination = requireDepositDestination(fields.destination); + const account = requireClearnetAccount(destination.account); + const reference = requireReference(destination.ref); + const amount = requireAmount(fields.amount); + const mint = resolveMint(fields.asset); + validateWaitOptions(waitOptions); + const transaction = new Transaction(); + transaction.feePayer = this.depositor; + transaction.add( + mint === undefined + ? this.depositSolInstruction(account, reference, amount) + : this.depositSplInstruction(mint, account, reference, amount), + ); + + const signature = await this.signAndSend(transaction); + const ref = txRef(signature); + waitOptions.onSubmitted?.(ref); + await this.waitForCommitment(signature, ref, waitOptions); + return ref; + } + + async verifyDeposit( + ref: TxRef, + minConfirmations: bigint | number, + ): Promise { + requireTxRef(ref); + const minConf = normalizeMinConfirmations(minConfirmations); + const status = await this.getSignatureStatus(ref.raw, ref); + return mapStatus(status, minConf); + } + + private depositSolInstruction( + account: Uint8Array, + reference: Uint8Array, + amount: bigint, + ): TransactionInstruction { + return new TransactionInstruction({ + programId: this.programId, + keys: [ + { pubkey: this.depositor, isSigner: true, isWritable: true }, + { pubkey: this.vault, isSigner: false, isWritable: true }, + { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, + { + pubkey: this.eventAuthority, + isSigner: false, + isWritable: false, + }, + { pubkey: this.programId, isSigner: false, isWritable: false }, + ], + data: encodeDepositData( + DEPOSIT_SOL_DISCRIMINATOR, + account, + reference, + amount, + ), + }); + } + + private depositSplInstruction( + mint: PublicKey, + account: Uint8Array, + reference: Uint8Array, + amount: bigint, + ): TransactionInstruction { + return new TransactionInstruction({ + programId: this.programId, + keys: [ + { pubkey: this.depositor, isSigner: true, isWritable: true }, + { pubkey: mint, isSigner: false, isWritable: false }, + { + pubkey: associatedTokenAddress(this.depositor, mint), + isSigner: false, + isWritable: true, + }, + { pubkey: this.vault, isSigner: false, isWritable: false }, + { + pubkey: associatedTokenAddress(this.vault, mint), + isSigner: false, + isWritable: true, + }, + { + pubkey: SOLANA_TOKEN_PROGRAM_PUBLIC_KEY, + isSigner: false, + isWritable: false, + }, + { + pubkey: SOLANA_ASSOCIATED_TOKEN_PROGRAM_PUBLIC_KEY, + isSigner: false, + isWritable: false, + }, + { + pubkey: this.eventAuthority, + isSigner: false, + isWritable: false, + }, + { pubkey: this.programId, isSigner: false, isWritable: false }, + ], + data: encodeDepositData( + DEPOSIT_SPL_DISCRIMINATOR, + account, + reference, + amount, + ), + }); + } + + private async signAndSend(transaction: Transaction): Promise { + try { + return await this.signer.signAndSend(transaction); + } catch (error) { + if (error instanceof ClearnetSdkError) { + throw error; + } + throw new ClearnetSdkError("RPC_ERROR", "sol: sign and send", { + cause: error, + }); + } + } + + private async waitForCommitment( + signature: string, + ref: TxRef, + options: SubmitDepositOptions, + ): Promise { + const timeoutMs = requireReceiptTimeout( + options.receiptTimeoutMs ?? this.receiptTimeoutMs, + ); + const deadline = Date.now() + timeoutMs; + for (;;) { + if (options.signal?.aborted === true) { + throw new ClearnetSdkError("RECEIPT_TIMEOUT", "sol: receipt aborted", { + txRef: ref, + }); + } + const status = await waitWithControls( + () => this.getSignatureStatus(signature, ref), + remainingMs(deadline, ref), + options.signal, + ref, + ); + if (status?.err != null) { + throw new ClearnetSdkError("TX_REVERTED", "sol: transaction failed", { + txRef: ref, + }); + } + if (statusSatisfiesCommitment(status, this.commitment)) { + return; + } + if (Date.now() >= deadline) { + throw new ClearnetSdkError("RECEIPT_TIMEOUT", "sol: receipt timeout", { + txRef: ref, + }); + } + await sleep(Math.min(POLL_INTERVAL_MS, remainingMs(deadline, ref)), options.signal, ref); + } + } + + private async getSignatureStatus( + signature: string, + txRef: TxRef | undefined, + ): Promise { + try { + const out = await this.connection.getSignatureStatuses([signature], { + searchTransactionHistory: true, + }); + return out.value[0] ?? null; + } catch (error) { + throw new ClearnetSdkError( + "RPC_ERROR", + "sol: signature status", + txRef === undefined ? { cause: error } : { txRef, cause: error }, + ); + } + } +} + +export function vaultPda(programId = SOLANA_CUSTODY_PUBLIC_KEY): PublicKey { + return PublicKey.findProgramAddressSync([VAULT_SEED], programId)[0]; +} + +export function eventAuthorityPda( + programId = SOLANA_CUSTODY_PUBLIC_KEY, +): PublicKey { + return PublicKey.findProgramAddressSync([EVENT_AUTHORITY_SEED], programId)[0]; +} + +function associatedTokenAddress(owner: PublicKey, mint: PublicKey): PublicKey { + return PublicKey.findProgramAddressSync( + [ + owner.toBytes(), + SOLANA_TOKEN_PROGRAM_PUBLIC_KEY.toBytes(), + mint.toBytes(), + ], + SOLANA_ASSOCIATED_TOKEN_PROGRAM_PUBLIC_KEY, + )[0]; +} + +function txRef(signature: string): TxRef { + let signatureBytes: Uint8Array; + try { + signatureBytes = bs58.decode(signature); + } catch (error) { + throw new ClearnetSdkError("INVALID_TX_REF", "Solana signature must be base58", { + cause: error, + }); + } + if (signatureBytes.length !== 64) { + throw new ClearnetSdkError( + "INVALID_TX_REF", + "Solana signature must decode to 64 bytes", + ); + } + return { hash: bytes32Hex(sha256(signatureBytes)), raw: signature }; +} + +function mapStatus( + status: SignatureStatusValue, + minConfirmations: bigint, +): DepositStatus { + if (status == null || status.err != null) { + return "absent"; + } + switch (status.confirmationStatus) { + case "finalized": + return "confirmed"; + case "confirmed": + return minConfirmations === 0n ? "confirmed" : "pending"; + default: + return "pending"; + } +} + +function statusSatisfiesCommitment( + status: SignatureStatusValue, + commitment: SolanaCommitment, +): boolean { + if (status == null || status.err != null) { + return false; + } + if (commitment === "processed") { + return true; + } + if (commitment === "confirmed") { + return ( + status.confirmationStatus === "confirmed" || + status.confirmationStatus === "finalized" + ); + } + return status.confirmationStatus === "finalized"; +} + +function validateWaitOptions(options: SubmitDepositOptions): void { + if (options.receiptTimeoutMs !== undefined) { + requireReceiptTimeout(options.receiptTimeoutMs); + } + if (options.signal?.aborted === true) { + throw new ClearnetSdkError("RECEIPT_TIMEOUT", "sol: receipt aborted"); + } +} + +function requireSubmitDepositOptions(options: unknown): SubmitDepositOptions { + if (options === null || typeof options !== "object") { + throw new ClearnetSdkError( + "RECEIPT_TIMEOUT", + "submit options must be an object", + ); + } + return options; +} + +function remainingMs(deadline: number, ref: TxRef): number { + const remaining = deadline - Date.now(); + if (remaining <= 0) { + throw new ClearnetSdkError("RECEIPT_TIMEOUT", "sol: receipt timeout", { + txRef: ref, + }); + } + return remaining; +} + +async function waitWithControls( + wait: () => Promise, + timeoutMs: number, + signal: AbortSignal | undefined, + ref: TxRef, +): Promise { + if (signal?.aborted === true) { + throw new ClearnetSdkError("RECEIPT_TIMEOUT", "sol: receipt aborted", { + txRef: ref, + }); + } + + let timeoutId: ReturnType | undefined; + let abortHandler: (() => void) | undefined; + + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject( + new ClearnetSdkError("RECEIPT_TIMEOUT", "sol: receipt timeout", { + txRef: ref, + }), + ); + }, timeoutMs); + }); + + const abortPromise = + signal === undefined + ? undefined + : new Promise((_, reject) => { + abortHandler = () => { + reject( + new ClearnetSdkError("RECEIPT_TIMEOUT", "sol: receipt aborted", { + txRef: ref, + }), + ); + }; + signal.addEventListener("abort", abortHandler, { once: true }); + }); + + try { + return await Promise.race( + abortPromise === undefined + ? [wait(), timeoutPromise] + : [wait(), timeoutPromise, abortPromise], + ); + } finally { + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } + if (signal !== undefined && abortHandler !== undefined) { + signal.removeEventListener("abort", abortHandler); + } + } +} + +async function sleep( + ms: number, + signal: AbortSignal | undefined, + txRef: TxRef, +): Promise { + await new Promise((resolve, reject) => { + if (signal?.aborted === true) { + reject( + new ClearnetSdkError("RECEIPT_TIMEOUT", "sol: receipt aborted", { + txRef, + }), + ); + return; + } + let abortHandler: (() => void) | undefined; + let timeout: ReturnType | undefined; + const cleanup = () => { + if (timeout !== undefined) { + clearTimeout(timeout); + } + if (signal !== undefined && abortHandler !== undefined) { + signal.removeEventListener("abort", abortHandler); + } + }; + timeout = setTimeout(() => { + cleanup(); + resolve(); + }, ms); + if (signal !== undefined) { + abortHandler = () => { + cleanup(); + reject( + new ClearnetSdkError("RECEIPT_TIMEOUT", "sol: receipt aborted", { + txRef, + }), + ); + }; + signal.addEventListener("abort", abortHandler, { once: true }); + } + }); +} diff --git a/sdk/ts/src/blockchain/sol/encoding.ts b/sdk/ts/src/blockchain/sol/encoding.ts new file mode 100644 index 0000000..ddf8899 --- /dev/null +++ b/sdk/ts/src/blockchain/sol/encoding.ts @@ -0,0 +1,15 @@ +import { Buffer } from "buffer"; + +export function encodeDepositData( + discriminator: readonly number[], + account: Uint8Array, + reference: Uint8Array, + amount: bigint, +): Buffer { + const data = new Uint8Array(8 + 20 + 32 + 8); + data.set(discriminator, 0); + data.set(account, 8); + data.set(reference, 28); + new DataView(data.buffer).setBigUint64(60, amount, true); + return Buffer.from(data); +} diff --git a/sdk/ts/src/blockchain/sol/index.ts b/sdk/ts/src/blockchain/sol/index.ts new file mode 100644 index 0000000..1100ddc --- /dev/null +++ b/sdk/ts/src/blockchain/sol/index.ts @@ -0,0 +1,17 @@ +export { + eventAuthorityPda, + SolanaVaultDepositor, + vaultPda, +} from "./depositor.js"; +export { + SOLANA_CUSTODY_PROGRAM_ID, + SOLANA_NATIVE_ASSET, +} from "./constants.js"; +export type { + SolanaAsset, + SolanaCommitment, + SolanaDepositDestination, + SolanaDepositorConfig, + SolanaSigner, + SolanaSubmitDepositInput, +} from "./types.js"; diff --git a/sdk/ts/src/blockchain/sol/types.ts b/sdk/ts/src/blockchain/sol/types.ts new file mode 100644 index 0000000..94e4c45 --- /dev/null +++ b/sdk/ts/src/blockchain/sol/types.ts @@ -0,0 +1,41 @@ +import type { Transaction } from "@solana/web3.js"; + +import type { + Bytes32Hex, + DepositDestination, + SubmitDepositInput, +} from "../../core/types.js"; + +export type SolanaAsset = string; + +export type SolanaCommitment = "processed" | "confirmed" | "finalized"; + +export interface SolanaDepositDestination extends DepositDestination { + account: string; + ref?: Bytes32Hex; +} + +export interface SolanaSubmitDepositInput extends SubmitDepositInput { + asset: SolanaAsset; + amount: bigint; + destination: SolanaDepositDestination; +} + +export interface SolanaSigner { + publicKey: string; + /** + * Signs and submits a @solana/web3.js v1 Transaction. + * + * Implementations must set transaction.recentBlockhash, usually from + * getLatestBlockhash, before signing. + */ + signAndSend(transaction: Transaction): Promise; +} + +export interface SolanaDepositorConfig { + rpcUrl: string; + signer: SolanaSigner; + programId?: string; + commitment?: SolanaCommitment; + receiptTimeoutMs?: number; +} diff --git a/sdk/ts/src/blockchain/sol/validation.ts b/sdk/ts/src/blockchain/sol/validation.ts new file mode 100644 index 0000000..ac0361b --- /dev/null +++ b/sdk/ts/src/blockchain/sol/validation.ts @@ -0,0 +1,245 @@ +import { Buffer } from "buffer"; + +import bs58 from "bs58"; +import { sha256 } from "@noble/hashes/sha2.js"; +import { PublicKey } from "@solana/web3.js"; + +import { ClearnetSdkError } from "../../core/errors.js"; +import type { Bytes32Hex, DepositDestination, TxRef } from "../../core/types.js"; +import { + DEFAULT_SOLANA_COMMITMENT, + SOLANA_CUSTODY_PROGRAM_ID, +} from "./constants.js"; +import type { SolanaCommitment, SolanaSigner } from "./types.js"; + +const UINT64_MAX = (1n << 64n) - 1n; +const BYTES32_HEX_PATTERN = /^0x[a-fA-F0-9]{64}$/; + +export function requireRpcUrl(rpcUrl: unknown): string { + if (typeof rpcUrl !== "string" || rpcUrl.trim() === "") { + throw new ClearnetSdkError("RPC_ERROR", "rpcUrl is required"); + } + return rpcUrl; +} + +export function requireSigner(signer: unknown): SolanaSigner { + if (!signer || typeof signer !== "object") { + throw new ClearnetSdkError( + "MISSING_WALLET_ACCOUNT", + "Solana signer is required", + ); + } + const candidate = signer as Partial; + publicKeyFromString(candidate.publicKey, "signer.publicKey"); + if (typeof candidate.signAndSend !== "function") { + throw new ClearnetSdkError( + "MISSING_WALLET_ACCOUNT", + "Solana signer.signAndSend is required", + ); + } + return candidate as SolanaSigner; +} + +export function requireProgramId(value: unknown): PublicKey { + const programId = + value === undefined + ? new PublicKey(SOLANA_CUSTODY_PROGRAM_ID) + : publicKeyFromString(value, "programId"); + if (programId.toBase58() !== SOLANA_CUSTODY_PROGRAM_ID) { + throw new ClearnetSdkError( + "INVALID_ADDRESS", + `programId must be ${SOLANA_CUSTODY_PROGRAM_ID}`, + ); + } + return programId; +} + +export function normalizeCommitment( + commitment: SolanaCommitment | undefined, +): SolanaCommitment { + if (commitment === undefined) { + return DEFAULT_SOLANA_COMMITMENT; + } + if ( + commitment !== "processed" && + commitment !== "confirmed" && + commitment !== "finalized" + ) { + throw new ClearnetSdkError( + "RPC_ERROR", + "commitment must be processed, confirmed, or finalized", + ); + } + return commitment; +} + +export function requireReceiptTimeout(value: number): number { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new ClearnetSdkError( + "RECEIPT_TIMEOUT", + "receiptTimeoutMs must be a positive safe integer", + ); + } + return value; +} + +export function requireAmount(amount: unknown): bigint { + if (typeof amount !== "bigint") { + throw new ClearnetSdkError( + "INVALID_AMOUNT", + "amount must be a bigint in base units", + ); + } + if (amount <= 0n) { + throw new ClearnetSdkError( + "INVALID_AMOUNT", + "amount must be greater than zero", + ); + } + if (amount > UINT64_MAX) { + throw new ClearnetSdkError("INVALID_AMOUNT", "amount must fit in uint64"); + } + return amount; +} + +export function requireDepositDestination( + destination: unknown, +): DepositDestination { + if (!destination || typeof destination !== "object") { + throw new ClearnetSdkError( + "INVALID_ADDRESS", + "destination.account must be a 20-byte hex address", + ); + } + return destination as DepositDestination; +} + +export function requireClearnetAccount(account: unknown): Uint8Array { + if (typeof account !== "string") { + throw new ClearnetSdkError( + "INVALID_ADDRESS", + "destination.account must be a 20-byte hex address", + ); + } + const segment = account.slice(account.lastIndexOf("/") + 1); + const hex = segment.toLowerCase().replace(/^0x/, ""); + if (!/^[a-f0-9]+$/.test(hex) || hex.length !== 40) { + throw new ClearnetSdkError( + "INVALID_ADDRESS", + "destination.account must be a 20-byte hex address", + ); + } + return Uint8Array.from(Buffer.from(hex, "hex")); +} + +export function requireReference(reference: unknown): Uint8Array { + if (reference === undefined || reference === "") { + return new Uint8Array(32); + } + if (typeof reference !== "string" || !BYTES32_HEX_PATTERN.test(reference)) { + throw new ClearnetSdkError( + "INVALID_REFERENCE", + "destination.ref must be a 32-byte hex value", + ); + } + return Uint8Array.from(Buffer.from(reference.slice(2), "hex")); +} + +export function resolveMint(asset: unknown): PublicKey | undefined { + if ( + asset === "" || + asset === "native" || + asset === "SOL" || + asset === "sol" + ) { + return undefined; + } + return publicKeyFromString(asset, "asset"); +} + +export function publicKeyFromString(value: unknown, field: string): PublicKey { + if (typeof value !== "string") { + throw new ClearnetSdkError( + "INVALID_ADDRESS", + `${field} must be a valid Solana public key`, + ); + } + try { + return new PublicKey(value); + } catch (error) { + throw new ClearnetSdkError( + "INVALID_ADDRESS", + `${field} must be a valid Solana public key`, + { cause: error }, + ); + } +} + +export function requireTxRef(ref: unknown): Uint8Array { + if (!ref || typeof ref !== "object") { + throw new ClearnetSdkError( + "INVALID_TX_REF", + "ref.raw must be a Solana signature", + ); + } + const fields = ref as Partial; + 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", + ); + } + if (typeof fields.raw !== "string") { + throw new ClearnetSdkError( + "INVALID_TX_REF", + "ref.raw must be a Solana signature", + ); + } + let signature: Uint8Array; + try { + signature = bs58.decode(fields.raw); + } catch (error) { + throw new ClearnetSdkError( + "INVALID_TX_REF", + "ref.raw must be a Solana signature", + { cause: error }, + ); + } + if (signature.length !== 64) { + throw new ClearnetSdkError( + "INVALID_TX_REF", + "ref.raw must decode to a 64-byte Solana signature", + ); + } + if (fields.hash.toLowerCase() !== bytes32Hex(sha256(signature))) { + throw new ClearnetSdkError( + "INVALID_TX_REF", + "ref.hash must match the Solana signature hash", + ); + } + return signature; +} + +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); +} + +export function bytes32Hex(bytes: Uint8Array): Bytes32Hex { + const hex = [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join(""); + return `0x${hex}`; +} diff --git a/sdk/ts/src/core/types.ts b/sdk/ts/src/core/types.ts index cd5175e..7826058 100644 --- a/sdk/ts/src/core/types.ts +++ b/sdk/ts/src/core/types.ts @@ -1,13 +1,14 @@ import type { Account, Address, - Hash, PublicClient, WalletClient, } from "viem"; +export type Bytes32Hex = `0x${string}`; + export interface TxRef { - hash: Hash; + hash: Bytes32Hex; raw: string; } @@ -15,7 +16,7 @@ export type DepositStatus = "absent" | "pending" | "confirmed"; export interface DepositDestination { account: string; - ref?: Hash; + ref?: Bytes32Hex; } export interface EvmDepositDestination extends DepositDestination { diff --git a/sdk/ts/src/index.ts b/sdk/ts/src/index.ts index c7c73cf..4457f59 100644 --- a/sdk/ts/src/index.ts +++ b/sdk/ts/src/index.ts @@ -1,4 +1,5 @@ export type { + Bytes32Hex, DepositDestination, DepositStatus, EvmDepositDestination, @@ -13,3 +14,18 @@ export { ClearnetSdkError } from "./core/errors.js"; export type { ClearnetSdkErrorCode } from "./core/errors.js"; export { EvmVaultDepositor } from "./blockchain/evm/depositor.js"; export { EVM_NATIVE_ASSET } from "./blockchain/evm/constants.js"; +export { + eventAuthorityPda, + SOLANA_CUSTODY_PROGRAM_ID, + SOLANA_NATIVE_ASSET, + SolanaVaultDepositor, + vaultPda, +} from "./blockchain/sol/index.js"; +export type { + SolanaAsset, + SolanaCommitment, + SolanaDepositDestination, + SolanaDepositorConfig, + SolanaSigner, + SolanaSubmitDepositInput, +} from "./blockchain/sol/index.js"; diff --git a/sdk/ts/test/blockchain/sol/depositor.integration.test.ts b/sdk/ts/test/blockchain/sol/depositor.integration.test.ts new file mode 100644 index 0000000..61cdcc0 --- /dev/null +++ b/sdk/ts/test/blockchain/sol/depositor.integration.test.ts @@ -0,0 +1,267 @@ +import { + Connection, + Keypair, + LAMPORTS_PER_SOL, + PublicKey, + sendAndConfirmTransaction, + Transaction, +} from "@solana/web3.js"; +import bs58 from "bs58"; +import { beforeAll, describe, expect, it } from "vitest"; + +import { + SOLANA_NATIVE_ASSET, + SolanaVaultDepositor, + vaultPda, +} from "../../../src/index.js"; +import type { SolanaSigner } from "../../../src/index.js"; +import { DEPOSITED_EVENT_DISCRIMINATOR } from "../../../src/blockchain/sol/constants.js"; +import { bytes32Hex } from "../../../src/blockchain/sol/validation.js"; +import { + createAssociatedTokenAccountIdempotent, + createMint, + getAssociatedTokenAddress, + mintTo, + tokenBalance, +} from "./spl-test-helpers.js"; + +const RPC_URL = process.env.SOL_RPC_URL ?? "http://127.0.0.1:8899"; +const ACCOUNT = "00000000000000000000000000000000000000a1"; +const REFERENCE = + "0x3333333333333333333333333333333333333333333333333333333333333333"; +const connection = new Connection(RPC_URL, "confirmed"); +const DEPOSITED_EVENT_SIZE = 8 + 32 + 20 + 32 + 32 + 8; + +describe("SolanaVaultDepositor validator integration", () => { + beforeAll(async () => { + const version = await connection.getVersion(); + expect(version["solana-core"]).toBeTruthy(); + }, 60_000); + + it("deposits native SOL and verifies the deposit tx", async () => { + const depositorKeypair = Keypair.generate(); + await airdrop(depositorKeypair.publicKey, LAMPORTS_PER_SOL); + const signer = new KeypairSolanaSigner(depositorKeypair); + const depositor = new SolanaVaultDepositor({ + rpcUrl: RPC_URL, + signer, + commitment: "confirmed", + }); + const vault = vaultPda(); + const amount = 100_000_000n; + const beforeBalance = BigInt(await connection.getBalance(vault)); + + const ref = await depositor.submitDeposit({ + asset: SOLANA_NATIVE_ASSET, + amount, + destination: { account: ACCOUNT, ref: REFERENCE }, + }); + + const afterBalance = await waitForLamports(vault, beforeBalance + amount); + expect(afterBalance - beforeBalance).toBe(amount); + await expect(depositor.verifyDeposit(ref, 0)).resolves.toBe("confirmed"); + await expectDepositedEvent(ref.raw, { + depositor: depositorKeypair.publicKey, + account: ACCOUNT, + reference: REFERENCE, + mint: PublicKey.default, + amount, + }); + }, 120_000); + + it("deposits SPL tokens and verifies the deposit tx", async () => { + const payer = Keypair.generate(); + await airdrop(payer.publicKey, LAMPORTS_PER_SOL); + const signer = new KeypairSolanaSigner(payer); + const depositor = new SolanaVaultDepositor({ + rpcUrl: RPC_URL, + signer, + commitment: "confirmed", + }); + const mint = await createMint(connection, payer, payer.publicKey, 0); + const depositorAta = await createAssociatedTokenAccountIdempotent( + connection, + payer, + mint, + payer.publicKey, + ); + const amount = 25n; + await mintTo(connection, payer, mint, depositorAta, payer, amount); + const vaultTokenAccount = await createAssociatedTokenAccountIdempotent( + connection, + payer, + mint, + vaultPda(), + ); + const vaultAta = getAssociatedTokenAddress(mint, vaultPda()); + expect(vaultTokenAccount.toBase58()).toBe(vaultAta.toBase58()); + const beforeBalance = await tokenBalance(connection, vaultAta); + expect(beforeBalance).toBe(0n); + + const ref = await depositor.submitDeposit({ + asset: mint.toBase58(), + amount, + destination: { account: ACCOUNT, ref: REFERENCE }, + }); + + const afterBalance = await waitForTokenBalance(vaultAta, beforeBalance + amount); + expect(afterBalance - beforeBalance).toBe(amount); + await expect(depositor.verifyDeposit(ref, 0)).resolves.toBe("confirmed"); + await expectDepositedEvent(ref.raw, { + depositor: payer.publicKey, + account: ACCOUNT, + reference: REFERENCE, + mint, + amount, + }); + }, 120_000); +}); + +class KeypairSolanaSigner implements SolanaSigner { + readonly publicKey: string; + + constructor(private readonly keypair: Keypair) { + this.publicKey = keypair.publicKey.toBase58(); + } + + async signAndSend(transaction: Transaction): Promise { + return sendAndConfirmTransaction(connection, transaction, [this.keypair], { + commitment: "confirmed", + preflightCommitment: "confirmed", + }); + } +} + +async function airdrop(pubkey: PublicKey, lamports: number): Promise { + const signature = await connection.requestAirdrop(pubkey, lamports); + await connection.confirmTransaction(signature, "confirmed"); +} + +interface ExpectedDepositedEvent { + depositor: PublicKey; + account: string; + reference: string; + mint: PublicKey; + amount: bigint; +} + +interface DepositedEvent { + depositor: PublicKey; + account: Uint8Array; + reference: Uint8Array; + mint: PublicKey; + amount: bigint; +} + +async function expectDepositedEvent( + signature: string, + expected: ExpectedDepositedEvent, +): Promise { + const event = await readDepositedEvent(signature); + expect(event.depositor.toBase58()).toBe(expected.depositor.toBase58()); + expect(stripHexPrefix(bytes32Hex(event.account))).toBe( + stripHexPrefix(expected.account), + ); + expect(stripHexPrefix(bytes32Hex(event.reference))).toBe( + stripHexPrefix(expected.reference), + ); + expect(event.mint.toBase58()).toBe(expected.mint.toBase58()); + expect(event.amount).toBe(expected.amount); +} + +async function readDepositedEvent(signature: string): Promise { + const deadline = Date.now() + 30_000; + for (;;) { + const transaction = await connection.getTransaction(signature, { + commitment: "confirmed", + maxSupportedTransactionVersion: 0, + }); + const innerInstructions = transaction?.meta?.innerInstructions ?? []; + for (const group of innerInstructions) { + for (const instruction of group.instructions) { + const event = decodeDepositedEvent(bs58.decode(instruction.data)); + if (event !== undefined) { + return event; + } + } + } + if (Date.now() >= deadline) { + throw new Error(`Deposited event not found in ${signature}`); + } + await sleep(250); + } +} + +function decodeDepositedEvent(data: Uint8Array): DepositedEvent | undefined { + const eventOffset = findBytes(data, DEPOSITED_EVENT_DISCRIMINATOR); + if (eventOffset < 0 || data.length < eventOffset + DEPOSITED_EVENT_SIZE) { + return undefined; + } + let cursor = eventOffset + DEPOSITED_EVENT_DISCRIMINATOR.length; + const depositor = new PublicKey(data.subarray(cursor, cursor + 32)); + cursor += 32; + const account = data.slice(cursor, cursor + 20); + cursor += 20; + const reference = data.slice(cursor, cursor + 32); + cursor += 32; + const mint = new PublicKey(data.subarray(cursor, cursor + 32)); + cursor += 32; + const amountBytes = data.subarray(cursor, cursor + 8); + const amount = new DataView( + amountBytes.buffer, + amountBytes.byteOffset, + amountBytes.byteLength, + ).getBigUint64(0, true); + return { depositor, account, reference, mint, amount }; +} + +function findBytes(data: Uint8Array, needle: readonly number[]): number { + for (let offset = 0; offset <= data.length - needle.length; offset += 1) { + if (needle.every((byte, index) => data[offset + index] === byte)) { + return offset; + } + } + return -1; +} + +function stripHexPrefix(value: string): string { + return value.startsWith("0x") ? value.slice(2) : value; +} + +async function waitForLamports( + pubkey: PublicKey, + target: bigint, +): Promise { + const deadline = Date.now() + 30_000; + for (;;) { + const balance = BigInt(await connection.getBalance(pubkey)); + if (balance >= target) { + return balance; + } + if (Date.now() >= deadline) { + throw new Error(`timed out waiting for ${pubkey.toBase58()} balance`); + } + await sleep(250); + } +} + +async function waitForTokenBalance( + address: PublicKey, + target: bigint, +): Promise { + const deadline = Date.now() + 30_000; + for (;;) { + const balance = await tokenBalance(connection, address); + if (balance >= target) { + return balance; + } + if (Date.now() >= deadline) { + throw new Error(`timed out waiting for ${address.toBase58()} token balance`); + } + await sleep(250); + } +} + +async function sleep(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/sdk/ts/test/blockchain/sol/depositor.test.ts b/sdk/ts/test/blockchain/sol/depositor.test.ts new file mode 100644 index 0000000..6989eb7 --- /dev/null +++ b/sdk/ts/test/blockchain/sol/depositor.test.ts @@ -0,0 +1,648 @@ +import bs58 from "bs58"; +import { sha256 } from "@noble/hashes/sha2.js"; +import { + PublicKey, + SystemProgram, + Transaction, + type TransactionInstruction, +} from "@solana/web3.js"; +import { afterEach, describe, expect, expectTypeOf, it, vi } from "vitest"; + +import { + ClearnetSdkError, + eventAuthorityPda as sdkEventAuthorityPda, + SOLANA_CUSTODY_PROGRAM_ID, + SOLANA_NATIVE_ASSET, + SolanaVaultDepositor, + vaultPda as sdkVaultPda, +} from "../../../src/index.js"; +import { + DEPOSIT_SOL_DISCRIMINATOR, + DEPOSIT_SPL_DISCRIMINATOR, + SOLANA_ASSOCIATED_TOKEN_PROGRAM_ID, + SOLANA_TOKEN_PROGRAM_ID, +} from "../../../src/blockchain/sol/constants.js"; +import { bytes32Hex } from "../../../src/blockchain/sol/validation.js"; +import type { + Bytes32Hex, + DepositStatus, + SolanaSigner, + SolanaSubmitDepositInput, + SubmitDepositOptions, + TxRef, + VaultDepositor, +} from "../../../src/index.js"; + +const RPC_URL = "http://127.0.0.1:8899"; +const EXPECTED_PROGRAM_ID = "98eVpih8X9CAcgU9bzNB9V7VtkRrnFZUmqzEnsq7cfmg"; +const PROGRAM_ID = new PublicKey(EXPECTED_PROGRAM_ID); +const DEPOSITOR = publicKey(11); +const MINT = publicKey(22); +const ACCOUNT = "0x1111111111111111111111111111111111111111"; +const ACCOUNT_URI = `yellow://local/user/${ACCOUNT.slice(2)}`; +const REFERENCE = + "0x2222222222222222222222222222222222222222222222222222222222222222" as Bytes32Hex; +const SIGNATURE = bs58.encode(Uint8Array.from({ length: 64 }, (_, i) => i + 1)); + +const SYSTEM_PROGRAM_ID = SystemProgram.programId.toBase58(); +const TOKEN_PROGRAM_ID = SOLANA_TOKEN_PROGRAM_ID; +const ASSOCIATED_TOKEN_PROGRAM_ID = SOLANA_ASSOCIATED_TOKEN_PROGRAM_ID; +const VAULT_PDA = sdkVaultPda(PROGRAM_ID).toBase58(); +const EVENT_AUTHORITY_PDA = sdkEventAuthorityPda(PROGRAM_ID).toBase58(); + +interface MockSigner extends SolanaSigner { + signAndSend: ReturnType< + typeof vi.fn<(transaction: Transaction) => Promise> + >; +} + +describe("SolanaVaultDepositor", () => { + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it("matches the public depositor and result type contracts", () => { + expectTypeOf().toMatchTypeOf< + VaultDepositor + >(); + expectTypeOf().toEqualTypeOf<{ hash: Bytes32Hex; raw: string }>(); + expectTypeOf().toEqualTypeOf< + "absent" | "pending" | "confirmed" + >(); + expect(SOLANA_NATIVE_ASSET).toBe("SOL"); + expect(SOLANA_CUSTODY_PROGRAM_ID).toBe(EXPECTED_PROGRAM_ID); + }); + + it("submits native SOL with the deposit_sol layout and Go-compatible tx ref", async () => { + stubSignatureStatus({ confirmationStatus: "finalized" }); + const signer = createSigner(); + const depositor = createDepositor(signer); + const onSubmitted = vi.fn(); + + const ref = await depositor.submitDeposit( + { + asset: SOLANA_NATIVE_ASSET, + amount: 10n, + destination: { account: ACCOUNT_URI, ref: REFERENCE }, + }, + { onSubmitted }, + ); + + expect(ref).toEqual(txRefForSignature(SIGNATURE)); + expect(onSubmitted).toHaveBeenCalledExactlyOnceWith(ref); + + const tx = signedTransaction(signer); + expect(tx.feePayer?.toBase58()).toBe(DEPOSITOR.toBase58()); + expect(tx.instructions).toHaveLength(1); + + const instruction = tx.instructions[0]!; + expect(instruction.programId.toBase58()).toBe(EXPECTED_PROGRAM_ID); + expect(metas(instruction)).toEqual([ + meta(DEPOSITOR, true, true), + meta(VAULT_PDA, false, true), + meta(SYSTEM_PROGRAM_ID, false, false), + meta(EVENT_AUTHORITY_PDA, false, false), + meta(PROGRAM_ID, false, false), + ]); + expect([...instruction.data]).toEqual([ + ...DEPOSIT_SOL_DISCRIMINATOR, + ...hexBytes(ACCOUNT), + ...hexBytes(REFERENCE), + ...u64(10n), + ]); + }); + + it("submits SPL tokens with ATA derivation and the deposit_spl layout", async () => { + stubSignatureStatus({ confirmationStatus: "finalized" }); + const signer = createSigner(); + const depositor = createDepositor(signer); + + const ref = await depositor.submitDeposit({ + asset: MINT.toBase58(), + amount: 25n, + destination: { account: ACCOUNT }, + }); + + expect(ref).toEqual(txRefForSignature(SIGNATURE)); + + const instruction = signedTransaction(signer).instructions[0]!; + expect(instruction.programId.toBase58()).toBe(EXPECTED_PROGRAM_ID); + expect(metas(instruction)).toEqual([ + meta(DEPOSITOR, true, true), + meta(MINT, false, false), + meta(ata(DEPOSITOR, MINT), false, true), + meta(VAULT_PDA, false, false), + meta(ata(VAULT_PDA, MINT), false, true), + meta(TOKEN_PROGRAM_ID, false, false), + meta(ASSOCIATED_TOKEN_PROGRAM_ID, false, false), + meta(EVENT_AUTHORITY_PDA, false, false), + meta(PROGRAM_ID, false, false), + ]); + expect([...instruction.data]).toEqual([ + ...DEPOSIT_SPL_DISCRIMINATOR, + ...hexBytes(ACCOUNT), + ...new Uint8Array(32), + ...u64(25n), + ]); + }); + + it("rejects invalid deposit input before signing", async () => { + const fetch = vi.fn(); + vi.stubGlobal("fetch", fetch); + const signer = createSigner(); + const depositor = createDepositor(signer); + + await expect( + depositor.submitDeposit(null as unknown as SolanaSubmitDepositInput), + ).rejects.toMatchObject({ code: "INVALID_ADDRESS" }); + await expect( + depositor.submitDeposit( + { + asset: SOLANA_NATIVE_ASSET, + amount: 1n, + destination: { account: ACCOUNT }, + }, + null as unknown as SubmitDepositOptions, + ), + ).rejects.toMatchObject({ code: "RECEIPT_TIMEOUT" }); + await expect( + depositor.submitDeposit({ + asset: SOLANA_NATIVE_ASSET, + amount: 1n, + destination: null as unknown as SolanaSubmitDepositInput["destination"], + }), + ).rejects.toMatchObject({ code: "INVALID_ADDRESS" }); + await expect( + depositor.submitDeposit({ + asset: SOLANA_NATIVE_ASSET, + amount: 1n, + destination: { account: "0x1234" }, + }), + ).rejects.toMatchObject({ code: "INVALID_ADDRESS" }); + await expect( + depositor.submitDeposit({ + asset: "not-base58", + amount: 1n, + destination: { account: ACCOUNT }, + }), + ).rejects.toMatchObject({ code: "INVALID_ADDRESS" }); + await expect( + depositor.submitDeposit({ + asset: SOLANA_NATIVE_ASSET, + amount: 0n, + destination: { account: ACCOUNT }, + }), + ).rejects.toMatchObject({ code: "INVALID_AMOUNT" }); + await expect( + depositor.submitDeposit({ + asset: SOLANA_NATIVE_ASSET, + amount: 1 as unknown as bigint, + destination: { account: ACCOUNT }, + }), + ).rejects.toMatchObject({ code: "INVALID_AMOUNT" }); + await expect( + depositor.submitDeposit({ + asset: SOLANA_NATIVE_ASSET, + amount: 1n << 64n, + destination: { account: ACCOUNT }, + }), + ).rejects.toMatchObject({ code: "INVALID_AMOUNT" }); + await expect( + depositor.submitDeposit({ + asset: SOLANA_NATIVE_ASSET, + amount: 1n, + destination: { account: ACCOUNT, ref: "invoice-1" as Bytes32Hex }, + }), + ).rejects.toMatchObject({ code: "INVALID_REFERENCE" }); + + expect(signer.signAndSend).not.toHaveBeenCalled(); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("requires the default program ID for v1", () => { + const signer = createSigner(); + + expect(() => + new SolanaVaultDepositor({ + rpcUrl: RPC_URL, + signer, + programId: publicKey(33).toBase58(), + }), + ).toThrow(ClearnetSdkError); + expect(() => + new SolanaVaultDepositor({ rpcUrl: "", signer }), + ).toThrow(ClearnetSdkError); + expect(() => + new SolanaVaultDepositor({ + rpcUrl: RPC_URL, + signer: undefined as unknown as SolanaSigner, + }), + ).toThrow(ClearnetSdkError); + expect(() => + new SolanaVaultDepositor({ + rpcUrl: RPC_URL, + signer: { + publicKey: "not-base58", + signAndSend: async () => SIGNATURE, + }, + }), + ).toThrow(ClearnetSdkError); + expect(() => + new SolanaVaultDepositor({ + rpcUrl: RPC_URL, + signer: { publicKey: DEPOSITOR.toBase58() } as unknown as SolanaSigner, + }), + ).toThrow(ClearnetSdkError); + expect(() => + new SolanaVaultDepositor({ + rpcUrl: RPC_URL, + signer, + commitment: "recent" as never, + }), + ).toThrow(ClearnetSdkError); + expect(() => + new SolanaVaultDepositor({ + rpcUrl: RPC_URL, + signer, + receiptTimeoutMs: 0, + }), + ).toThrow(ClearnetSdkError); + }); + + it("wraps signer failures before a tx ref exists", async () => { + const cause = new Error("wallet rejected"); + stubSignatureStatus({ confirmationStatus: "finalized" }); + const signer = createSigner(); + signer.signAndSend.mockRejectedValue(cause); + const depositor = createDepositor(signer); + const onSubmitted = vi.fn(); + + await expect( + depositor.submitDeposit( + { + asset: SOLANA_NATIVE_ASSET, + amount: 1n, + destination: { account: ACCOUNT }, + }, + { onSubmitted }, + ), + ).rejects.toMatchObject({ + code: "RPC_ERROR", + cause, + txRef: undefined, + }); + expect(onSubmitted).not.toHaveBeenCalled(); + }); + + it("rejects invalid signer-returned signatures before submission callback", async () => { + stubSignatureStatus({ confirmationStatus: "finalized" }); + const signer = createSigner(); + signer.signAndSend.mockResolvedValue("not a base58 signature"); + const depositor = createDepositor(signer); + const onSubmitted = vi.fn(); + + await expect( + depositor.submitDeposit( + { + asset: SOLANA_NATIVE_ASSET, + amount: 1n, + destination: { account: ACCOUNT }, + }, + { onSubmitted }, + ), + ).rejects.toMatchObject({ code: "INVALID_TX_REF" }); + expect(onSubmitted).not.toHaveBeenCalled(); + }); + + it("attaches txRef when a post-broadcast status lookup fails", async () => { + const rpcError = new Error("node offline"); + stubRpcFailure(rpcError); + const signer = createSigner(); + const depositor = createDepositor(signer); + const expectedRef = txRefForSignature(SIGNATURE); + + await expect( + depositor.submitDeposit({ + asset: SOLANA_NATIVE_ASSET, + amount: 1n, + destination: { account: ACCOUNT }, + }), + ).rejects.toMatchObject({ + code: "RPC_ERROR", + txRef: expectedRef, + cause: rpcError, + }); + }); + + it("attaches txRef when a submitted transaction reports an execution error", async () => { + stubSignatureStatus({ + confirmationStatus: "confirmed", + err: { InstructionError: [0, "Custom"] }, + }); + const signer = createSigner(); + const depositor = createDepositor(signer); + const expectedRef = txRefForSignature(SIGNATURE); + + await expect( + depositor.submitDeposit({ + asset: SOLANA_NATIVE_ASSET, + amount: 1n, + destination: { account: ACCOUNT }, + }), + ).rejects.toMatchObject({ + code: "TX_REVERTED", + txRef: expectedRef, + }); + }); + + it("attaches txRef when a submitted transaction times out", async () => { + vi.useFakeTimers(); + stubSignatureStatus(null); + const signer = createSigner(); + const depositor = createDepositor(signer); + const expectedRef = txRefForSignature(SIGNATURE); + + const promise = depositor.submitDeposit( + { + asset: SOLANA_NATIVE_ASSET, + amount: 1n, + destination: { account: ACCOUNT }, + }, + { receiptTimeoutMs: 1_000 }, + ); + const assertion = expect(promise).rejects.toMatchObject({ + code: "RECEIPT_TIMEOUT", + txRef: expectedRef, + }); + await vi.advanceTimersByTimeAsync(1_000); + await assertion; + }); + + it("attaches txRef when a submitted transaction is aborted while waiting", async () => { + vi.useFakeTimers(); + stubSignatureStatus(null); + const signer = createSigner(); + const depositor = createDepositor(signer); + const controller = new AbortController(); + const expectedRef = txRefForSignature(SIGNATURE); + + const promise = depositor.submitDeposit( + { + asset: SOLANA_NATIVE_ASSET, + amount: 1n, + destination: { account: ACCOUNT }, + }, + { + signal: controller.signal, + receiptTimeoutMs: 10_000, + onSubmitted() { + setTimeout(() => controller.abort(), 1); + }, + }, + ); + const assertion = expect(promise).rejects.toMatchObject({ + code: "RECEIPT_TIMEOUT", + txRef: expectedRef, + }); + await vi.advanceTimersByTimeAsync(1); + await assertion; + }); + + it("rejects invalid wait options before signing", async () => { + const signer = createSigner(); + const depositor = createDepositor(signer); + const controller = new AbortController(); + controller.abort(); + + await expect( + depositor.submitDeposit( + { + asset: SOLANA_NATIVE_ASSET, + amount: 1n, + destination: { account: ACCOUNT }, + }, + { receiptTimeoutMs: 0 }, + ), + ).rejects.toMatchObject({ code: "RECEIPT_TIMEOUT" }); + await expect( + depositor.submitDeposit( + { + asset: SOLANA_NATIVE_ASSET, + amount: 1n, + destination: { account: ACCOUNT }, + }, + { signal: controller.signal }, + ), + ).rejects.toMatchObject({ code: "RECEIPT_TIMEOUT" }); + expect(signer.signAndSend).not.toHaveBeenCalled(); + }); + + it("bounds a hung post-submit status lookup", async () => { + vi.useFakeTimers(); + vi.stubGlobal("fetch", vi.fn(() => new Promise(() => undefined))); + const signer = createSigner(); + const depositor = createDepositor(signer); + const expectedRef = txRefForSignature(SIGNATURE); + + const promise = depositor.submitDeposit( + { + asset: SOLANA_NATIVE_ASSET, + amount: 1n, + destination: { account: ACCOUNT }, + }, + { receiptTimeoutMs: 1_000 }, + ); + const assertion = expect(promise).rejects.toMatchObject({ + code: "RECEIPT_TIMEOUT", + txRef: expectedRef, + }); + await vi.advanceTimersByTimeAsync(1_000); + await assertion; + }); + + it("maps Solana signature statuses to the shared deposit status", async () => { + const depositor = createDepositor(createSigner()); + + stubSignatureStatus({ confirmationStatus: "confirmed" }); + await expect(depositor.verifyDeposit(txRefForSignature(SIGNATURE), 0)).resolves.toBe( + "confirmed", + ); + + stubSignatureStatus({ confirmationStatus: "confirmed" }); + await expect(depositor.verifyDeposit(txRefForSignature(SIGNATURE), 1)).resolves.toBe( + "pending", + ); + + stubSignatureStatus({ confirmationStatus: "finalized" }); + await expect(depositor.verifyDeposit(txRefForSignature(SIGNATURE), 1n)).resolves.toBe( + "confirmed", + ); + + stubSignatureStatus({ confirmationStatus: "processed" }); + await expect(depositor.verifyDeposit(txRefForSignature(SIGNATURE), 0)).resolves.toBe( + "pending", + ); + + stubSignatureStatus(null); + await expect(depositor.verifyDeposit(txRefForSignature(SIGNATURE), 0)).resolves.toBe( + "absent", + ); + + stubSignatureStatus({ confirmationStatus: "finalized", err: { InstructionError: [0, "Custom"] } }); + await expect(depositor.verifyDeposit(txRefForSignature(SIGNATURE), 0)).resolves.toBe( + "absent", + ); + }); + + it("validates tx refs and confirmation depths before RPC", async () => { + const fetch = vi.fn(); + vi.stubGlobal("fetch", fetch); + const depositor = createDepositor(createSigner()); + + await expect( + depositor.verifyDeposit({ hash: txRefForSignature(SIGNATURE).hash, raw: "bad sig" }, 0), + ).rejects.toMatchObject({ code: "INVALID_TX_REF" }); + await expect( + depositor.verifyDeposit({ hash: "0x1234" as Bytes32Hex, raw: SIGNATURE }, 0), + ).rejects.toMatchObject({ code: "INVALID_TX_REF" }); + await expect( + depositor.verifyDeposit( + { + hash: txRefForSignature(bs58.encode(new Uint8Array(64).fill(9))).hash, + raw: SIGNATURE, + }, + 0, + ), + ).rejects.toMatchObject({ code: "INVALID_TX_REF" }); + await expect( + depositor.verifyDeposit(txRefForSignature(SIGNATURE), -1), + ).rejects.toMatchObject({ code: "INVALID_CONFIRMATIONS" }); + await expect( + depositor.verifyDeposit(txRefForSignature(SIGNATURE), 1.5), + ).rejects.toMatchObject({ code: "INVALID_CONFIRMATIONS" }); + + expect(fetch).not.toHaveBeenCalled(); + }); + + it("preserves txRef when verifyDeposit status lookup fails", async () => { + const rpcError = new Error("node offline"); + stubRpcFailure(rpcError); + const depositor = createDepositor(createSigner()); + const ref = txRefForSignature(SIGNATURE); + + await expect(depositor.verifyDeposit(ref, 0)).rejects.toMatchObject({ + code: "RPC_ERROR", + txRef: ref, + cause: rpcError, + }); + }); +}); + +function createDepositor(signer: SolanaSigner): SolanaVaultDepositor { + return new SolanaVaultDepositor({ rpcUrl: RPC_URL, signer }); +} + +function createSigner(): MockSigner { + return { + publicKey: DEPOSITOR.toBase58(), + signAndSend: vi.fn<(transaction: Transaction) => Promise>() + .mockResolvedValue(SIGNATURE), + }; +} + +function signedTransaction(signer: MockSigner): Transaction { + const call = signer.signAndSend.mock.calls[0]; + if (call === undefined) { + throw new Error("signAndSend was not called"); + } + return call[0]; +} + +function publicKey(seed: number): PublicKey { + return new PublicKey(Uint8Array.from({ length: 32 }, (_, i) => seed + i)); +} + +function ata(owner: PublicKey | string, mint: PublicKey): string { + const ownerKey = typeof owner === "string" ? new PublicKey(owner) : owner; + return PublicKey.findProgramAddressSync( + [ + ownerKey.toBytes(), + new PublicKey(TOKEN_PROGRAM_ID).toBytes(), + mint.toBytes(), + ], + new PublicKey(ASSOCIATED_TOKEN_PROGRAM_ID), + )[0].toBase58(); +} + +function meta( + pubkey: PublicKey | string, + isSigner: boolean, + isWritable: boolean, +): { pubkey: string; isSigner: boolean; isWritable: boolean } { + return { + pubkey: typeof pubkey === "string" ? pubkey : pubkey.toBase58(), + isSigner, + isWritable, + }; +} + +function metas(instruction: TransactionInstruction): ReturnType[] { + return instruction.keys.map((key) => + meta(key.pubkey, key.isSigner, key.isWritable), + ); +} + +function hexBytes(value: string): Uint8Array { + const hex = value.toLowerCase().replace(/^0x/, ""); + return Uint8Array.from(Buffer.from(hex, "hex")); +} + +function u64(value: bigint): Uint8Array { + const bytes = new Uint8Array(8); + new DataView(bytes.buffer).setBigUint64(0, value, true); + return bytes; +} + +function txRefForSignature(signature: string): TxRef { + const signatureBytes = bs58.decode(signature); + return { hash: bytes32Hex(sha256(signatureBytes)), raw: signature }; +} + +function stubSignatureStatus( + value: null | { confirmationStatus: string; err?: unknown }, +): void { + const response = { + jsonrpc: "2.0", + id: "1", + result: { + context: { slot: 1 }, + value: [ + value === null + ? null + : { + slot: 1, + confirmations: + value.confirmationStatus === "finalized" ? null : 1, + err: value.err ?? null, + confirmationStatus: value.confirmationStatus, + }, + ], + }, + }; + vi.stubGlobal( + "fetch", + vi.fn().mockImplementation(() => + Promise.resolve( + new Response(JSON.stringify(response), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ), + ), + ); +} + +function stubRpcFailure(error: Error): void { + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(error)); +} diff --git a/sdk/ts/test/blockchain/sol/spl-test-helpers.ts b/sdk/ts/test/blockchain/sol/spl-test-helpers.ts new file mode 100644 index 0000000..2e9f51b --- /dev/null +++ b/sdk/ts/test/blockchain/sol/spl-test-helpers.ts @@ -0,0 +1,149 @@ +import { Buffer } from "buffer"; + +import { + Connection, + Keypair, + PublicKey, + sendAndConfirmTransaction, + SystemProgram, + Transaction, + TransactionInstruction, +} from "@solana/web3.js"; + +import { + SOLANA_ASSOCIATED_TOKEN_PROGRAM_ID, + SOLANA_TOKEN_PROGRAM_ID, +} from "../../../src/blockchain/sol/constants.js"; + +const MINT_ACCOUNT_SIZE = 82; +const INITIALIZE_MINT2_INSTRUCTION = 20; +const CREATE_IDEMPOTENT_ATA_INSTRUCTION = 1; +const MINT_TO_INSTRUCTION = 7; +const TOKEN_AMOUNT_OFFSET = 1; +const TOKEN_AMOUNT_BYTES = 8; +const TOKEN_AMOUNT_LENGTH = TOKEN_AMOUNT_OFFSET + TOKEN_AMOUNT_BYTES; + +const TOKEN_PROGRAM_ID = new PublicKey(SOLANA_TOKEN_PROGRAM_ID); +const ASSOCIATED_TOKEN_PROGRAM_ID = new PublicKey( + SOLANA_ASSOCIATED_TOKEN_PROGRAM_ID, +); + +export function getAssociatedTokenAddress( + mint: PublicKey, + owner: PublicKey, +): PublicKey { + return PublicKey.findProgramAddressSync( + [owner.toBuffer(), TOKEN_PROGRAM_ID.toBuffer(), mint.toBuffer()], + ASSOCIATED_TOKEN_PROGRAM_ID, + )[0]; +} + +export async function createMint( + connection: Connection, + payer: Keypair, + mintAuthority: PublicKey, + decimals: number, +): Promise { + const mint = Keypair.generate(); + const lamports = + await connection.getMinimumBalanceForRentExemption(MINT_ACCOUNT_SIZE); + const transaction = new Transaction().add( + SystemProgram.createAccount({ + fromPubkey: payer.publicKey, + newAccountPubkey: mint.publicKey, + lamports, + space: MINT_ACCOUNT_SIZE, + programId: TOKEN_PROGRAM_ID, + }), + new TransactionInstruction({ + programId: TOKEN_PROGRAM_ID, + keys: [{ pubkey: mint.publicKey, isSigner: false, isWritable: true }], + data: Buffer.concat([ + Buffer.from([INITIALIZE_MINT2_INSTRUCTION, decimals]), + mintAuthority.toBuffer(), + Buffer.from([0]), + ]), + }), + ); + await sendAndConfirmTransaction(connection, transaction, [payer, mint], { + commitment: "confirmed", + }); + return mint.publicKey; +} + +export async function createAssociatedTokenAccountIdempotent( + connection: Connection, + payer: Keypair, + mint: PublicKey, + owner: PublicKey, +): Promise { + const ata = getAssociatedTokenAddress(mint, owner); + const transaction = new Transaction().add( + new TransactionInstruction({ + programId: ASSOCIATED_TOKEN_PROGRAM_ID, + keys: [ + { pubkey: payer.publicKey, isSigner: true, isWritable: true }, + { pubkey: ata, isSigner: false, isWritable: true }, + { pubkey: owner, isSigner: false, isWritable: false }, + { pubkey: mint, isSigner: false, isWritable: false }, + { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, + { pubkey: TOKEN_PROGRAM_ID, isSigner: false, isWritable: false }, + ], + data: Buffer.from([CREATE_IDEMPOTENT_ATA_INSTRUCTION]), + }), + ); + await sendAndConfirmTransaction(connection, transaction, [payer], { + commitment: "confirmed", + }); + return ata; +} + +export async function mintTo( + connection: Connection, + payer: Keypair, + mint: PublicKey, + destination: PublicKey, + authority: Keypair, + amount: bigint, +): Promise { + const data = Buffer.alloc(TOKEN_AMOUNT_LENGTH); + data[0] = MINT_TO_INSTRUCTION; + data.writeBigUInt64LE(amount, TOKEN_AMOUNT_OFFSET); + const transaction = new Transaction().add( + new TransactionInstruction({ + programId: TOKEN_PROGRAM_ID, + keys: [ + { pubkey: mint, isSigner: false, isWritable: true }, + { pubkey: destination, isSigner: false, isWritable: true }, + { pubkey: authority.publicKey, isSigner: true, isWritable: false }, + ], + data, + }), + ); + await sendAndConfirmTransaction( + connection, + transaction, + uniqueSigners([payer, authority]), + { commitment: "confirmed" }, + ); +} + +export async function tokenBalance( + connection: Connection, + ata: PublicKey, +): Promise { + const account = await connection.getAccountInfo(ata); + if (account === null) { + return 0n; + } + const balance = await connection.getTokenAccountBalance(ata); + return BigInt(balance.value.amount); +} + +function uniqueSigners(signers: Keypair[]): Keypair[] { + const out = new Map(); + for (const signer of signers) { + out.set(signer.publicKey.toBase58(), signer); + } + return [...out.values()]; +}