diff --git a/.gitignore b/.gitignore index 1e2b3be..ec50cb5 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,7 @@ yarn-error.log* # JUnit test-result output (CI artifact) /reports/ +/junit.xml # oh-my-claudecode runtime state (operational artifacts, never committed) .omc/ diff --git a/.pnpmfile.cjs b/.pnpmfile.cjs index b2bca3e..da93c60 100644 --- a/.pnpmfile.cjs +++ b/.pnpmfile.cjs @@ -4,9 +4,9 @@ * pnpm hook to resolve @wireio packages from the local wire-libraries-ts monorepo. * * Usage: - * 1. Add packages you want to link to the `localOverrides` map below. - * 2. Run `pnpm install` — pnpm will use these local paths instead of the registry. - * 3. Comment out or remove entries to revert to registry versions. + * 1. Build any supported sibling package outputs you want to use locally. + * 2. Run `pnpm install --lockfile=false` to consume available local packages. + * 3. Remove those outputs to resolve packages from the registry again. * * Docs: https://pnpm.io/pnpmfile */ @@ -28,30 +28,32 @@ function isDirectory(dirPath) { } } -/** - * Map of package names to their local directory in wire-libraries-ts. - * Uncomment the entries you want to link locally. - */ +/** Map of locally available packages keyed by package name. */ const localOverrides = {} // AS THE PROTOBUF LIBS HAVE BEEN RELOCATED TO SYSIO // WE CAN NOW USE THE MODELS WITHOUT ISSUE. // CIRCULAR DEP REMOVED -const wireOPPPkgPaths = ["typescript", "solidity"].map(target => [ - `@wireio/opp-${target}-models`, - Path.resolve(__dirname, "..", "wire-sysio", "build", "opp", target) -]) +const wirePkgPaths = [ + ...["typescript", "solidity"].map(target => [ + `@wireio/opp-${target}-models`, + Path.resolve(__dirname, "..", "wire-sysio", "build", "opp", target) + ]), + ...["ethereum","solana"].map(target => [ + `@wireio/outpost-${target}-artifacts`, + Path.resolve(__dirname, "..", `wire-${target}`, "build", "sdk-artifacts") + ]) +] -wireOPPPkgPaths +wirePkgPaths .filter(([, path]) => isDirectory(path)) .forEach(([pkgName, path]) => { localOverrides[pkgName] = path }) /** - * `readPackage` hook, which links locally available versions of - * shared libraries and models. + * `readPackage` hook, which links locally available packages. * * @param pkg * @param context diff --git a/CLAUDE.md b/CLAUDE.md index a6eac91..5aa5f5c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,9 +5,7 @@ ## Build & Development ```bash -pnpm install # Install registry deps (pnpm 10.34.5, Node >=22) -# Link local OPP models from wire-sysio: -WIRE_LINK_LOCAL_OPP_MODELS=1 pnpm install --lockfile=false +pnpm install # Install deps (pnpm 10.34.5, Node >=22); available sibling OPP models link automatically pnpm build # Build all packages via tsc -b pnpm build:dev # Watch mode (incremental) pnpm test # Build + jest (all packages) @@ -36,6 +34,7 @@ pnpm workspaces with TypeScript composite project references. No Lerna/Nx. | `@wireio/shared-web` | Web-specific utilities | No | ESM | | `@wireio/shared-node` | Node.js utilities | Yes | Hybrid ESM+CJS | | `@wireio/sdk-core` | Wire blockchain SDK types/primitives | Yes | Hybrid ESM+CJS | +| `@wireio/sdk-outpost` | Typed, verified external-chain outpost clients | No — first release pending | Hybrid ESM+CJS | | `@wireio/wallet-ext-sdk` | Wallet extension client SDK | Yes | ESM | | `@wireio/wallet-browser-ext` | Chrome extension developer wallet | No | Webpack bundle | @@ -46,6 +45,8 @@ shared ──→ shared-web ──→ shared-node sdk-core ──→ wallet-ext-sdk ──→ wallet-browser-ext + +source artifact libraries ──→ sdk-outpost external-chain clients ``` Protoc plugins and bundler are standalone (no internal deps). @@ -68,7 +69,7 @@ Root `tsconfig.json` has project references to all packages. Build order is reso ## Hybrid ESM/CJS Build Pattern -Packages that publish both ESM and CJS (`shared`, `sdk-core`, `shared-node`) use: +Packages that publish both ESM and CJS (`shared`, `sdk-core`, `sdk-outpost`, `shared-node`) use: 1. Two tsconfig files: one for `lib/esm/`, one for `lib/cjs/` 2. Post-build: `scripts/fix-hybrid-output.mjs` patches relative imports with `.js` extensions and creates `lib/cjs/package.json` with `{"type":"commonjs"}` @@ -191,12 +192,19 @@ Every new/modified symbol ships unit tests in the same change. Tests never assum ## CI/CD -GitHub Actions (`.github/workflows/publish-npm.yaml`): -- Triggers on push to `master` (skips if `[skip release]` in commit message) -- Bumps all packages patch version (`pnpm -r exec -- pnpm version patch`) -- Auto-commits `chore(release): bump patch [skip release]` -- Publishes all non-private packages to npm with provenance (`pnpm -r publish --access public --provenance`) -- Published package manifests must keep `repository.url` set to `https://github.com/Wire-Network/wire-libraries-ts` so npm provenance matches GitHub Actions source metadata. +GitHub Actions uses a two-gate release flow: +- `prepare-release.yaml` is manually dispatched with a bump type. It bumps each + package on its own version track and opens a release-preparation PR; it never + pushes directly to `master` or publishes. +- After that PR is reviewed and merged, `tag-release.yaml` is manually + dispatched and pauses for approval in the `release` environment. +- The approved job installs the workspace, builds and tests, publishes all + non-private packages in dependency order, creates the annotated tag, and + creates the GitHub release. +- `workspace:*` dependencies become concrete current workspace versions at + publish time. Published package manifests must keep `repository.url` set to + `https://github.com/Wire-Network/wire-libraries-ts` so npm provenance matches + GitHub Actions source metadata. ## Documentation Comments @@ -216,6 +224,15 @@ All generated or modified code **must** include JSDoc comments (`/** ... */`), c - System-contract `prepare` prefers synchronous ABI encoding but must retain a typed `AnyAction` fallback so `APIClient` can resolve the deployed ABI. Never invent a default write authorization in the public SDK. - `packages/sdk-core/src/contracts/sysio/reserv` owns public `sysio.reserv` registry reads, normalized rows, matching, rewards, and read-only quote helpers. External-chain reserve custody belongs in the ABI/IDL-owning chain SDK. - `packages/sdk-core/src/contracts/sysio/uwrit` preserves raw request bytes and exposes `sourceRequestId` for swap correlation. External outpost ids are big-endian; synthetic WIRE queue ids are little-endian and high-bit tagged. +- `packages/sdk-outpost` owns typed Ethereum/Solana clients and validates caller-supplied immutable deployment profiles. Canonical ABIs, IDLs, runtime templates, program binaries, ethers v6 factories, and Anchor types come from exact packages owned by `wire-ethereum` and `wire-solana`. For local platform development, the root pnpm hook links available sibling artifact outputs; never commit `file:`/`link:` specs to package manifests or the lockfile. +- Producer repositories assemble those npm packages from checksummed deployment handoffs. `sdk-outpost` never downloads the handoff or owns producer publication inputs. +- `sdk-outpost` registers exact producer package pairs in an internal compile-time artifact-suite registry. Select suites from deployment-profile ABI/IDL identity and verify exact live runtime identity; never key suite selection by environment names or turn it into an endpoint catalog/runtime package loader. +- `OutpostClient.create` is sdk-outpost's only published client-construction facade. It delegates family selection to an internal factory; concrete Ethereum/Solana instance types remain available for typing, but their backend factories and module paths are not package entrypoints. +- `packages/sdk-outpost` owns external reserve lifecycle, swap execution, and BAR-backed Ethereum node-owner registration. BAR is an optional deployment capability: profiles without it preserve reserve and swap behavior, while node-owner access fails closed. Node-owner registration must use BAR's canonical WireNodes address and is not staking; staking remains outside this package until its dedicated migration. +- `sdk-outpost` accepts caller-owned providers and deployment profiles, verifies exact Ethereum implementations and Solana ProgramData against source-owned runtime artifacts, and never owns mutable endpoint catalogs. A same-code cluster respin requires a new profile, not an artifact or SDK release; any deployable binary change requires both a producer artifact and SDK release. +- A connected outpost client proves deployment compatibility, not swap or stake readiness. Wire-chain orchestration remains in `sdk-core`, and consumers must retain flow-specific capability gates. +- Publish `sdk-outpost` only through the repository release workflow after its normal build and tests pass. +- Do not describe `sdk-outpost` as npm-available until `npm view` succeeds for both exact producer artifact versions and `@wireio/sdk-outpost`, and a clean platform-compatible install passes without sibling artifact links. - `wallet-browser-ext` uses a global shim to avoid `new Function()` restrictions in Chrome MV3 - Path aliases in tsconfig base resolve to `src/` for dev, but published packages use `lib/` — jest module name maps handle this mismatch - Node >=22 required (package.json says >=22, README says >=24 — actual CI uses v24) diff --git a/README.md b/README.md index dedfcf2..46d4ca2 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,17 @@ A monorepo containing shared TypeScript libraries for Wire applications, providi | [`@wireio/shared-web`](packages/shared-web/) | Web-specific utilities | *private* | | [`@wireio/shared-node`](packages/shared-node/) | Node.js-specific utilities | *private* | | [`@wireio/sdk-core`](packages/sdk-core/) | Wire blockchain SDK core types, primitives, signing helpers, generated `sysio` contract proxy, and domain workflows such as multisig and reserves | [![npm](https://img.shields.io/npm/v/@wireio/sdk-core)](https://www.npmjs.com/package/@wireio/sdk-core) | +| [`@wireio/sdk-outpost`](packages/sdk-outpost/) | Strictly typed Ethereum and Solana outpost clients consuming source-owned artifact libraries | *first release pending* | | [`@wireio/wallet-ext-sdk`](packages/wallet-ext-sdk/) | Client SDK for the Wire Wallet browser extension | [![npm](https://img.shields.io/npm/v/@wireio/wallet-ext-sdk)](https://www.npmjs.com/package/@wireio/wallet-ext-sdk) | | [`@wireio/wallet-browser-ext`](packages/wallet-browser-ext/) | Chrome extension developer wallet for Wire | *private* | +The sdk-outpost package consumes exact published versions of the Ethereum and +Solana artifact libraries, including their ethers v6 factories and Anchor +types. Chain bindings are generated and verified by those producer repos, not +inside this monorepo. An internal compile-time artifact-suite registry selects +compatible producer bindings from caller-supplied deployment profiles without +owning endpoints or environment configuration. + ## Examples | Example | Description | @@ -30,9 +38,6 @@ A monorepo containing shared TypeScript libraries for Wire applications, providi # Install dependencies pnpm install -# Install with locally generated OPP models from wire-sysio -WIRE_LINK_LOCAL_OPP_MODELS=1 pnpm install --lockfile=false - # Build all packages pnpm build @@ -56,6 +61,7 @@ wire-libraries-ts/ │ ├── shared-web/ # Web-specific utilities │ ├── shared-node/ # Node.js-specific utilities │ ├── sdk-core/ # Wire blockchain SDK core +│ ├── sdk-outpost/ # Typed external-chain outpost SDK │ ├── wallet-ext-sdk/ # Wallet extension client SDK │ ├── wallet-browser-ext/ # Chrome extension wallet │ ├── protoc-gen-solana/ # protoc plugin → Rust/Solana diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..8bb575b --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,116 @@ +# Releasing `@wireio/sdk-outpost` + +`@wireio/sdk-outpost` participates in the same repository-wide release as +`@wireio/sdk-core`. Do not manually change its version or publish a workspace +directory outside this process. + +## Current first-release state + +Ethereum and Solana producer packages `0.3.0` are the exact prerequisites for +the first SDK release. Both are publicly installable and provide directly +importable TypeScript libraries, ethers v6 factories, Anchor types, manifests, +and runtime artifacts. The first `@wireio/sdk-outpost` release remains pending +platform review. Its workspace version remains `0.0.0` until the existing +repository-wide patch workflow bumps and publishes it. + +## Artifact prerequisites + +The package consumes exact runtime versions of: + +- `@wireio/outpost-ethereum-artifacts`, published from `wire-ethereum`; +- `@wireio/outpost-solana-artifacts`, published from `wire-solana`. + +Publish a new producer package when its ABI/IDL or deployable contract/program +binary changes. Publish `sdk-outpost` when its public behavior changes or a new +ABI, IDL, normalized Ethereum runtime, or Solana program binary must be compiled +into deployment verification. A same-code deployment respin, address change, or +endpoint rotation does not require either package to be republished; emit a new +immutable deployment profile for a respin and update the separate endpoint +catalog for mutable endpoints. + +Before updating either dependency, verify its registry integrity/signature, +source revision, artifact checksums, and immutable version. The producer repos +are non-public, so their current npm releases do not carry public provenance. +Keep both versions exact in `packages/sdk-outpost/package.json`. Update +`pnpm-lock.yaml` with the repository-pinned pnpm version only after both exact +versions resolve from npm, then run the repository install and test flow. + +## Release requirements + +- Use Node.js 24 and the repository-pinned pnpm version through Corepack. +- Import ethers v6 factories, Anchor types, runtime artifacts, and manifests + directly from the exact producer packages; `sdk-outpost` must not regenerate + or duplicate them. +- Confirm the package contains no secrets, RPC credentials, private keys, + deployment addresses, or mutable environment configuration. +- Confirm deployment profiles are distributed through the authenticated + platform release channel; their schema and checksum-derived IDs are not + signatures. +- Keep `repository.url` exactly equal to + `https://github.com/Wire-Network/wire-libraries-ts` for npm provenance. + +Local checkouts are verification environments, not publishing authorities. Run +the checks there, but let the protected GitHub Actions workflow create the +release. + +## Before merge + +From the repository root: + +```sh +corepack pnpm install --no-frozen-lockfile --ignore-scripts +corepack pnpm run lint +corepack pnpm run test:ci +corepack pnpm --dir packages/sdk-outpost run build +``` + +Package verification requires the publishable files to remain limited to the +README and CJS/ESM build outputs. Raw producer packages and generated source +trees must not be published by `sdk-outpost`. + +## First npm listing + +The first successful publish creates the npm package page. Release sequence: + +1. Confirm both exact `0.3.0` producer artifact versions are publicly + installable. +2. Confirm the `wireio` organization exists on npm and the release owner can + publish public packages in that scope. +3. Confirm npm two-factor authentication is enabled for the release owner. +4. Confirm the GitHub repository secret `NPM_TOKEN` can publish to the `wireio` + organization under its required authentication policy. +5. Confirm the GitHub `release` environment exists with required reviewers; the + environment approval is the second release gate. +6. Merge the reviewed feature pull request into `master`. +7. Dispatch **Prepare Release** with the intended bump, approve its checks, and + merge the generated version-bump pull request. +8. Dispatch **Tag Release** and approve the `release` environment gate. + +The preparation gate keeps each package on its existing version track. A patch +bump therefore makes the first SDK release `@wireio/sdk-outpost@0.0.1`, while +`@wireio/sdk-core` advances by one patch on its independent track. +The Tag Release gate must install the workspace, build and test every package, +and publish with npm provenance. + +Do not create the first `sdk-outpost` version manually. A failed publish must be +corrected in source and released as the next patch; published npm versions are +immutable. + +## After the first publication + +Verify the listing and install path: + +```sh +npm view @wireio/sdk-outpost version dist-tags repository --json +npm install @wireio/sdk-outpost +``` + +Then configure npm trusted publishing for `tag-release.yaml`. After a trusted +publish succeeds, remove the long-lived write token from the publish step and +restrict token-based publishing in npm package settings. Keep `id-token: write` +so npm can generate provenance. + +References: + +- +- diff --git a/etc/tsconfig/tsconfig.base.json b/etc/tsconfig/tsconfig.base.json index 491de49..a79aea6 100755 --- a/etc/tsconfig/tsconfig.base.json +++ b/etc/tsconfig/tsconfig.base.json @@ -59,6 +59,12 @@ "@wireio/sdk-core/*": [ "./packages/sdk-core/src/*" ], + "@wireio/sdk-outpost": [ + "./packages/sdk-outpost/src" + ], + "@wireio/sdk-outpost/*": [ + "./packages/sdk-outpost/src/*" + ], "@wireio/wallet-ext-sdk": [ "./packages/wallet-ext-sdk/src" ], diff --git a/jest.config.ts b/jest.config.ts index 0679654..69d99f0 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -6,6 +6,7 @@ const config: Config = { "packages/shared-node", "packages/shared-web", "packages/sdk-core", + "packages/sdk-outpost", "packages/wallet-browser-ext", "packages/wallet-ext-sdk" ] diff --git a/packages/sdk-core/README.md b/packages/sdk-core/README.md index 483042f..1016b50 100644 --- a/packages/sdk-core/README.md +++ b/packages/sdk-core/README.md @@ -122,8 +122,23 @@ await reserves.pushMatchReserve({ matcher: "alice", wireAmount: pending[0].requestedWireAmount }) + +// Several pending rows can be activated atomically in one signed transaction. +await reserves.pushMatchReserves({ + matches: pending.map(reserve => ({ + chainCode: reserve.chainCode, + tokenCode: reserve.tokenCode, + reserveCode: reserve.reserveCode, + matcher: "alice", + wireAmount: reserve.requestedWireAmount + })) +}) ``` +`pushMatchReserves` preserves the supplied action order and rejects an empty +match list. The Wire transaction is atomic: either every `matchreserve` action +is accepted or none is applied. + ## Reserve swaps Reserve swap integrations compose three on-chain sources instead of carrying a diff --git a/packages/sdk-core/src/contracts/sysio/reserv/Client.ts b/packages/sdk-core/src/contracts/sysio/reserv/Client.ts index 8f000b9..a425e7f 100644 --- a/packages/sdk-core/src/contracts/sysio/reserv/Client.ts +++ b/packages/sdk-core/src/contracts/sysio/reserv/Client.ts @@ -14,7 +14,11 @@ import type * as SysioContracts from "../../../types/SysioContractTypes.js" import type { ContractTableRowsOptions } from "../../Contract.js" import { getSysioContract, type SysioContractClient } from "../Client.js" -import { buildSwapQuoteAction, matchReserveActionData } from "./Actions.js" +import { + buildMatchReserveAction, + buildSwapQuoteAction, + matchReserveActionData +} from "./Actions.js" import { DEFAULT_RESERV_CONTRACT, DEFAULT_RESERVE_QUERY_LIMIT @@ -27,6 +31,7 @@ import { import type { ListReservesOptions, PushMatchReserveOptions, + PushMatchReservesOptions, ReserveClientOptions, ReserveIdentity, ReserveQuoteOptions, @@ -34,6 +39,9 @@ import type { ReserveRewards } from "./Types.js" +/** Push-transaction response returned by the configured API client. */ +type APIClientPushTransactionResponse = ReturnType + function enumValue>( enumType: T, value: number | keyof T @@ -227,8 +235,7 @@ export class ReserveClient { limit: Number.MAX_SAFE_INTEGER }), row = rows.find( - candidate => - reserveRowSlugValue(candidate.reserve_code) === reserveCode + candidate => reserveRowSlugValue(candidate.reserve_code) === reserveCode ) return row ? normalizeReserveRow(row) : null @@ -256,7 +263,7 @@ export class ReserveClient { async pushMatchReserve( options: PushMatchReserveOptions, pushOptions: TransactionExtraOptions = options.pushOptions || {} - ): Promise>> { + ): APIClientPushTransactionResponse { return this.contractClient.actions.matchreserve.invoke( matchReserveActionData(options), { @@ -271,6 +278,21 @@ export class ReserveClient { ) } + /** Builds and pushes one signed Wire transaction that activates several pending reserves atomically. */ + async pushMatchReserves( + options: PushMatchReservesOptions, + pushOptions: TransactionExtraOptions = options.pushOptions || {} + ): APIClientPushTransactionResponse { + if (options.matches.length === 0) { + throw new Error("At least one reserve match is required.") + } + + return this.client.pushTransaction( + options.matches.map(buildMatchReserveAction), + pushOptions + ) + } + /** Reads the current on-chain quote for one reserve route. */ async getSwapQuote(options: ReserveQuoteOptions): Promise { const response = await this.sendReadOnlyAction( diff --git a/packages/sdk-core/src/contracts/sysio/reserv/Types.ts b/packages/sdk-core/src/contracts/sysio/reserv/Types.ts index cf0a434..ed5b265 100644 --- a/packages/sdk-core/src/contracts/sysio/reserv/Types.ts +++ b/packages/sdk-core/src/contracts/sysio/reserv/Types.ts @@ -59,6 +59,14 @@ export interface PushMatchReserveOptions extends MatchReserveOptions { pushOptions?: TransactionExtraOptions } +/** Options for atomically matching several pending reserves on Wire. */ +export interface PushMatchReservesOptions { + /** Ordered reserve matches included in one Wire transaction. */ + matches: readonly MatchReserveOptions[] + /** Optional push behavior such as finality waiting. */ + pushOptions?: TransactionExtraOptions +} + /** Options for a read-only reserve-to-reserve quote. */ export interface ReserveQuoteOptions { /** Source reserve identity. */ diff --git a/packages/sdk-core/tests/contracts/sysio/reserv/Client.test.ts b/packages/sdk-core/tests/contracts/sysio/reserv/Client.test.ts index 359a1c2..a52a606 100644 --- a/packages/sdk-core/tests/contracts/sysio/reserv/Client.test.ts +++ b/packages/sdk-core/tests/contracts/sysio/reserv/Client.test.ts @@ -163,6 +163,51 @@ describe("ReserveClient", () => { expect(action.authorization.map(String)).toEqual(["alice@active"]) }) + test("pushes ordered reserve matches in one Wire transaction", async () => { + const { client, pushTransaction } = clientFixture() + + await expect( + client.pushMatchReserves({ + matches: [ + { + chainCode: "ETHEREUM", + tokenCode: "ETH", + reserveCode: "PRIVATE", + matcher: "alice", + wireAmount: "2500000000" + }, + { + chainCode: "SOLANA", + tokenCode: "USDC", + reserveCode: "PRIVATE", + matcher: "alice", + wireAmount: "2500000000" + } + ] + }) + ).resolves.toEqual({ transaction_id: "reserve-trx" }) + + const [actions] = pushTransaction.mock.calls[0] + expect(actions).toHaveLength(2) + expect(actions.map(action => action.name.toString())).toEqual([ + "matchreserve", + "matchreserve" + ]) + expect(actions.map(action => action.authorization.map(String))).toEqual([ + ["alice@active"], + ["alice@active"] + ]) + }) + + test("rejects an empty atomic reserve match", async () => { + const { client, pushTransaction } = clientFixture() + + await expect(client.pushMatchReserves({ matches: [] })).rejects.toThrow( + "At least one reserve match is required." + ) + expect(pushTransaction).not.toHaveBeenCalled() + }) + test("decodes read-only swapquote and rewards values", async () => { const { client } = clientFixture() diff --git a/packages/sdk-outpost/README.md b/packages/sdk-outpost/README.md new file mode 100644 index 0000000..810f0ca --- /dev/null +++ b/packages/sdk-outpost/README.md @@ -0,0 +1,309 @@ +# `@wireio/sdk-outpost` + +Strictly typed access to the Ethereum contracts and Solana programs deployed +alongside a Wire chain. + +`@wireio/sdk-core` owns Wire-chain identity, signing, and `sysio.*` workflows. +This package owns verified external-chain clients. Contract ABIs are published +by `wire-ethereum`, the Solana IDL is published by `wire-solana`, and immutable +deployment profiles remain caller-supplied. + +The current source pins Ethereum and Solana artifacts `0.3.0`, directly +importable TypeScript libraries assembled from the same verified deployment +handoff. Their published manifests identify the exact producer commits, runtime +artifacts, and ethers v6/Anchor bindings used by the SDK. The workspace version +remains `0.0.0` until the repository release workflow performs its patch bump. + +## Install after the first SDK release + +```sh +npm install @wireio/sdk-outpost +``` + +Before installing, verify that `@wireio/sdk-outpost` resolves through `npm view`. +The SDK consumes exact npm versions of +[`@wireio/outpost-ethereum-artifacts`](https://www.npmjs.com/package/@wireio/outpost-ethereum-artifacts) +and +[`@wireio/outpost-solana-artifacts`](https://www.npmjs.com/package/@wireio/outpost-solana-artifacts); +do not replace them with committed machine-local links. + +Node.js 22 or newer and ethers v6 are supported. The package publishes CommonJS +and ES module entrypoints with TypeScript declarations. + +For local `wire-platform` development, the repository pnpm hook links available +sibling artifact-package outputs automatically. Run `pnpm install --lockfile=false` +after building those outputs; otherwise the exact registry versions remain in use. + +## Supported surfaces + +| Family | Generated clients and workflows | +| -------- | ------------------------------------------------------------------------- | +| Ethereum | `OPP`, `OPPInbound`, `OperatorRegistry`, `ReserveManager`, reserve lifecycle and swaps | +| Solana | `liqsol_core`, configured reserve lifecycle, native SOL and classic SPL reserve swaps | + +Client creation verifies all four boundaries before returning: + +- the supplied ABI/IDL digests match the producer packages compiled into this + SDK release; +- the provider is connected to the expected external chain; +- every Ethereum proxy resolves through its EIP-1967 implementation slot to the + configured implementation address, exact implementation code hash, and the + producer package's normalized runtime template; +- every Solana program resolves through the upgradeable loader to the configured + ProgramData account, exact ProgramData hash, and producer program binary. + +`OutpostClient.create` is the single public construction facade. Its family +discriminator preserves the precise `EthereumOutpostClient` or +`SolanaOutpostClient` instance type without publishing separate chain-specific +factory entrypoints or internal module paths. + +These checks prove deployment compatibility, not end-to-end feature readiness. +Applications must still gate swaps, staking, settlement, retry, funding, and +underwriting using platform capability evidence. + +## Deployment profiles + +The SDK does not contain a mutable network or endpoint catalog. Resolve the +selected Wire network group in the application, load its immutable deployment +profile from the platform release/deployment pipeline, and validate that +untrusted input with `parseOutpostDeploymentProfile`. + +Schema validation and the checksum-derived profile ID do not authenticate a +profile. Load profiles only through the platform's authenticated release +channel; deployment-profile signing and distribution remain release-pipeline +responsibilities rather than SDK-owned mutable network data. + +A deployment profile carries: + +- the full parent Wire chain ID; +- one deployment checksum and deployment-checksum-derived profile ID; +- the Ethereum chain ID, proxy addresses, implementation addresses, ABI hashes, + and exact live implementation code hashes; +- the Solana genesis hash, program and ProgramData addresses, IDL hash, and + exact live ProgramData hash. + +RPC URLs, private keys, wallet state, and mutable capability results are not SDK +data. Keep mutable RPC/explorer endpoints in a separate catalog that points to a +deployment-profile ID. A cluster respin with the same deployable code creates a +new profile without requiring a producer-artifact or SDK release. + +| Change | Producer artifact release | `sdk-outpost` release | Deployment profile | +| ------------------------------------------------------- | ------------------------- | --------------------- | ----------------------------------------- | +| Same-code chain respin | No | No | New | +| Contract/program binary change with unchanged ABI/IDL | Yes | Yes | New | +| ABI or IDL change | Yes | Yes | New | +| Asset/reserve onboarding without code/interface changes | No | No | Update operational configuration/evidence | +| RPC or explorer rotation | No | No | Update endpoint catalog only | + +## Artifact suite selection + +The SDK keeps an internal, compile-time registry of supported Ethereum and +Solana producer package pairs. Client creation selects compatible bindings from +the deployment profile's ABI or IDL digests, then verifies the selected +candidate against the exact live Ethereum runtime or Solana ProgramData before +returning a client. + +This registry is not a network, endpoint, or environment catalog. It contains no +RPC URLs, does not download code, and is not keyed by names such as sandbox or +devnet. A new deployment profile that uses an already-registered artifact suite +works without an SDK release. A deployable code or interface change requires a +producer artifact release, a corresponding internal suite entry, and an SDK +release; consumers continue to use the same `OutpostClient.create` facade. + +## Usage + +Validate caller-owned deployment data and provide the matching external-chain +provider: + +```ts +import { JsonRpcProvider } from "ethers" + +import { + EthereumContractName, + OutpostChainFamily, + OutpostClient, + parseOutpostDeploymentProfile +} from "@wireio/sdk-outpost" + +const profile = parseOutpostDeploymentProfile(platformRelease.outpostProfile) +const ethereum = await OutpostClient.create({ + family: OutpostChainFamily.ethereum, + options: { + profile, + connection: new JsonRpcProvider(ethereumRpcUrl) + } +}) +const reserves = ethereum.contract(EthereumContractName.ReserveManager) +``` + +Wallet-connected clients expose verified reserve-swap workflows without a +separate deployment address book: + +```ts +const submission = await ethereum.swaps.requestNative({ + sourceTokenCode, + sourceReserveCode, + sourceAmount, + targetChainCode, + targetTokenCode, + targetReserveCode, + targetRecipient, + targetAmount, + targetToleranceBps +}) + +// Correlate this protocol id with sysio.uwrit; the transaction hash alone is +// only source-chain submission evidence. +console.log(submission.sourceRequestId) +``` + +Ethereum also exposes `requestErc20WithApproval`, `nativeBalance`, and +`erc20Balance`. Solana exposes `requestNative`, `requestSpl`, `nativeBalance`, +and `splBalance` through the same `client.swaps` ownership boundary. + +## Ethereum node owners + +The verified Ethereum client exposes `nodeOwners` for the external half of the +node-owner flow. It resolves the canonical WireNodes ERC-1155 contract from +BAR, reads owned tiers, obtains approval only when needed, and submits +`BAR.commitNode`. Wire account authority parsing reuses `@wireio/sdk-core`; the +SDK validates that the uncompressed depositor key belongs to the EVM signer. +BAR is an optional deployment capability: schema-v1 profiles without a BAR +identity continue to support the existing reserve and swap clients, while +accessing `nodeOwners` fails closed with an explicit availability error. + +```ts +const slots = await ethereum.nodeOwners.ownedSlots(ownerAddress) +const submission = await ethereum.nodeOwners.commit({ + tokenId: slots[0].tokenId, + wireAccountName: Name.from(wireAccountName), + wirePublicKey: PublicKey.from(wirePublicKey), + depositorPublicKey +}) +``` + +This surface does not mint test tokens, guess a fallback contract, create the +Wire account directly, or infer protocol completion from the EVM receipt. Hub +must keep the action disabled unless deployment and capability evidence both +advertise the complete node-owner flow, then follow the resulting Wire-side +registration state separately. + +## Reserve lifecycle + +Wallet-connected clients expose the external half of the post-bootstrap +reserve lifecycle. The external create escrows reserve capital and emits the +attestation that creates a pending `sysio.reserv` row. A signed +`@wireio/sdk-core` `ReserveClient` then supplies the exact requested WIRE amount +and activates that row. + +```ts +const ethereumSubmission = await ethereum.reserves.createNative({ + tokenCode, + reserveCode, + externalTokenAmount, + requestedWireAmount, + connectorWeightBps: 5_000, + name: "Private ETH reserve", + description: "", + isPrivate: true, + creatorPubKey +}) + +const configuredTokens = await solana.reserves.getConfiguredTokens() +const splToken = configuredTokens.find(token => !token.isNative) +if (splToken == null) throw new Error("No configured SPL reserve token.") + +const solanaSubmission = await solana.reserves.create({ + tokenCode: splToken.tokenCode, + reserveCode, + externalTokenAmount: splAmount, + requestedWireAmount, + connectorWeightBps: 5_000, + name: "Private SPL reserve", + description: "", + isPrivate: true, + mint: splToken.mint +}) +``` + +Ethereum supports native creation, ERC-20 approval or permit creation, pending +cancellation, and local reserve reads. Solana supports deployment-configured +token discovery, instruction assembly, creation, pending cancellation, address +derivation, and local reserve reads. `cancel` is valid only while creation is +pending and drives the protocol refund path. + +The all-zero mint returned for a configured native SOL route is protocol +metadata, not an Anchor account. The current `create_reserve` account context +still requires a real placeholder SPL mint and the creator's token account for +native SOL creation. Consumers that have not provisioned those accounts should +select a configured non-native SPL route, as in the example above. + +Private is a routing constraint, not access control or confidentiality. Private +reserves cannot use WIRE as a swap endpoint; when either external route leg is +private, Wire requires both active reserves to have the same non-empty owner. +The current protocol exposes no creator withdrawal, close, or redemption after +activation. This SDK intentionally does not invent an active-reserve exit API. + +Solana uses the same facade and returns the precise Anchor program type at the +runtime program address: + +```ts +import { + OutpostChainFamily, + OutpostClient, + SolanaProgramName +} from "@wireio/sdk-outpost" + +const solana = await OutpostClient.create({ + family: OutpostChainFamily.solana, + options: { profile, provider: anchorProvider } +}) +const liqsol = solana.program(SolanaProgramName.liqsolCore) +``` + +The producer-owned `LiqsolCore` type preserves the IDL's literal account namespace, +including `Program["account"]["outpostConfig"]` and +`Program["account"]["reserve"]`. Import it from +`@wireio/outpost-solana-artifacts`; never widen the IDL to the base `Idl` type. + +## Artifact ownership + +`@wireio/outpost-ethereum-artifacts` and `@wireio/outpost-solana-artifacts` are +normal runtime dependencies. Their producers verify and publish the ABIs, IDL, +runtime bytes, manifests, ethers v6 factories, and Anchor types together from a +checksummed deployment artifact handoff. `sdk-outpost` imports those published +libraries directly, registers their generated bindings as one internal artifact +suite, and uses their manifests for live deployment verification; it does not +download handoffs or regenerate chain code. + +## Consumer boundaries + +- Use this package for typed external `ReserveManager`, `OperatorRegistry`, + `OPP`, `OPPInbound`, `liqsol_core`, reserve lifecycle, and source reserve-swap + execution. +- Use `@wireio/sdk-core` for Wire transaction construction, reserve and token + registries, underwriting state, and settlement correlation. +- Recreate external clients whenever the selected deployment profile changes. +- Combine SDK deployment verification with flow-specific capability gates before + enabling a product action. +- Ethereum reserve-swap submissions estimate the live call and add 25% gas + headroom for nested OPP execution; unused gas is not charged. + +## Maintainer commands + +```sh +pnpm --dir packages/sdk-outpost run build +pnpm --dir packages/sdk-outpost run test +``` + +The artifact tests read the installed producer-package payloads, verify their +published runtime checksums and required generated bindings, and assert that the +internal registry accepts only the exact paired Ethereum and Solana suite. + +Release versions are managed by the monorepo-wide patch workflow. See the +[repository release guide](https://github.com/Wire-Network/wire-libraries-ts/blob/master/RELEASING.md) +for artifact prerequisites and the verification checklist. + +## License + +FSL-1.1-Apache-2.0 diff --git a/packages/sdk-outpost/jest.config.ts b/packages/sdk-outpost/jest.config.ts new file mode 100644 index 0000000..b247257 --- /dev/null +++ b/packages/sdk-outpost/jest.config.ts @@ -0,0 +1,23 @@ +import type { Config } from "jest" + +const config: Config = { + displayName: "@wireio/sdk-outpost", + testEnvironment: "node", + roots: ["/tests"], + testMatch: ["**/*.test.ts"], + transform: { + "^.+\\.[tj]s$": [ + "ts-jest", + { + tsconfig: "/tsconfig.cjs.jest.json" + } + ] + }, + moduleNameMapper: { + "^@wireio/sdk-outpost$": "/src/index", + "^@wireio/sdk-outpost/(.*)$": "/src/$1", + "^(\\.\\.?/.*)\\.js$": "$1" + } +} + +export default config diff --git a/packages/sdk-outpost/package.json b/packages/sdk-outpost/package.json new file mode 100644 index 0000000..c3e42bb --- /dev/null +++ b/packages/sdk-outpost/package.json @@ -0,0 +1,58 @@ +{ + "name": "@wireio/sdk-outpost", + "description": "Strictly typed Ethereum and Solana clients for Wire outposts.", + "version": "0.0.0", + "private": false, + "repository": { + "type": "git", + "url": "https://github.com/Wire-Network/wire-libraries-ts", + "directory": "packages/sdk-outpost" + }, + "publishConfig": { + "access": "public" + }, + "homepage": "https://github.com/Wire-Network/wire-libraries-ts/tree/master/packages/sdk-outpost", + "bugs": { + "url": "https://github.com/Wire-Network/wire-libraries-ts/issues" + }, + "keywords": ["wire", "ethereum", "solana", "outpost", "sdk"], + "engines": { + "node": ">=22" + }, + "sideEffects": false, + "files": ["lib/cjs", "lib/esm", "README.md"], + "types": "lib/esm/index.d.ts", + "main": "lib/cjs/index.js", + "module": "lib/esm/index.js", + "exports": { + ".": { + "import": "./lib/esm/index.js", + "require": "./lib/cjs/index.js", + "types": "./lib/esm/index.d.ts" + } + }, + "access": "public", + "license": "FSL-1.1-Apache-2.0", + "scripts": { + "compile": "tsc -b tsconfig.json", + "compile:watch": "tsc -b tsconfig.json -w", + "build": "pnpm run compile && pnpm run fix:hybrid:exports", + "test": "NODE_OPTIONS=--experimental-vm-modules jest", + "fix:hybrid:exports": "node ../../scripts/fix-hybrid-output.mjs ." + }, + "dependencies": { + "@coral-xyz/anchor": "^0.32.1", + "@solana/spl-token": "^0.3.11", + "@solana/web3.js": "^1.98.4", + "@wireio/outpost-ethereum-artifacts": "0.3.0", + "@wireio/outpost-solana-artifacts": "0.3.0", + "@wireio/sdk-core": "workspace:*", + "ethers": "^6.15.0", + "ts-pattern": "^5.9.0", + "zod": "^4.4.3" + }, + "devDependencies": { + "rpc-websockets": "9.3.8", + "typescript": "6.0.2" + } +} diff --git a/packages/sdk-outpost/src/artifacts/Compatibility.ts b/packages/sdk-outpost/src/artifacts/Compatibility.ts new file mode 100644 index 0000000..8b76b6a --- /dev/null +++ b/packages/sdk-outpost/src/artifacts/Compatibility.ts @@ -0,0 +1,48 @@ +import { + EthereumContractName, + OutpostChainFamily, + SolanaProgramName, + type OutpostDeploymentProfile +} from "../deployments/index.js" +import { CurrentOutpostArtifactSuite } from "./Registry.js" +import { + assertEthereumRuntimeSuiteCompatibility, + assertOutpostArtifactSuiteCompatibility, + assertSolanaProgramSuiteCompatibility +} from "./SuiteCompatibility.js" + +/** Verify live Ethereum code against its source-owned runtime template. */ +export function assertEthereumRuntimeArtifactCompatibility( + contractName: EthereumContractName, + code: string +): void { + assertEthereumRuntimeSuiteCompatibility( + contractName, + code, + CurrentOutpostArtifactSuite + ) +} + +/** Verify live Solana executable bytes against the source-owned program binary. */ +export function assertSolanaProgramArtifactCompatibility( + programName: SolanaProgramName, + programData: Uint8Array +): void { + assertSolanaProgramSuiteCompatibility( + programName, + programData, + CurrentOutpostArtifactSuite + ) +} + +/** Verify that a deployment profile matches this SDK's source-owned interfaces. */ +export function assertOutpostArtifactCompatibility( + profile: OutpostDeploymentProfile, + family: OutpostChainFamily +): void { + assertOutpostArtifactSuiteCompatibility( + profile, + family, + CurrentOutpostArtifactSuite + ) +} diff --git a/packages/sdk-outpost/src/artifacts/Manifests.ts b/packages/sdk-outpost/src/artifacts/Manifests.ts new file mode 100644 index 0000000..7254c38 --- /dev/null +++ b/packages/sdk-outpost/src/artifacts/Manifests.ts @@ -0,0 +1,7 @@ +import { CurrentOutpostArtifactSuite } from "./Registry.js" + +/** Exact producer manifests compiled into this SDK release. */ +export const OutpostArtifactManifests = { + ethereum: CurrentOutpostArtifactSuite.ethereum.manifest, + solana: CurrentOutpostArtifactSuite.solana.manifest +} as const diff --git a/packages/sdk-outpost/src/artifacts/Registry.ts b/packages/sdk-outpost/src/artifacts/Registry.ts new file mode 100644 index 0000000..2ce4fef --- /dev/null +++ b/packages/sdk-outpost/src/artifacts/Registry.ts @@ -0,0 +1,185 @@ +import { + BAR__factory, + EthereumOutpostArtifactManifest, + OPPInbound__factory, + OPP__factory, + OperatorRegistry__factory, + ReserveManager__factory +} from "@wireio/outpost-ethereum-artifacts" +import { + SolanaOutpostArtifactManifest, + liqsolCoreIdl +} from "@wireio/outpost-solana-artifacts" +import { match } from "ts-pattern" + +import { + EthereumContractName, + OutpostChainFamily, + SolanaProgramName, + type OutpostDeploymentProfile +} from "../deployments/index.js" + +/** Generated ethers v6 factories keyed by deployment identity. */ +export interface EthereumOutpostArtifactFactories { + readonly [EthereumContractName.BAR]: typeof BAR__factory + readonly [EthereumContractName.OPP]: typeof OPP__factory + readonly [EthereumContractName.OPPInbound]: typeof OPPInbound__factory + readonly [EthereumContractName.OperatorRegistry]: typeof OperatorRegistry__factory + readonly [EthereumContractName.ReserveManager]: typeof ReserveManager__factory +} + +/** Generated Anchor IDLs keyed by deployment identity. */ +export interface SolanaOutpostArtifactIdls { + readonly [SolanaProgramName.liqsolCore]: typeof liqsolCoreIdl +} + +/** Ethereum producer bindings and manifest owned by one artifact release. */ +export interface EthereumOutpostArtifactSuite { + /** Exact producer manifest used for compatibility verification. */ + readonly manifest: typeof EthereumOutpostArtifactManifest + /** Generated ethers v6 factories keyed by deployment identity. */ + readonly factories: EthereumOutpostArtifactFactories +} + +/** Solana producer bindings and manifest owned by one artifact release. */ +export interface SolanaOutpostArtifactSuite { + /** Exact producer manifest used for compatibility verification. */ + readonly manifest: typeof SolanaOutpostArtifactManifest + /** Generated Anchor IDLs keyed by deployment identity. */ + readonly idls: SolanaOutpostArtifactIdls +} + +/** Paired producer bindings accepted by one SDK Outpost release. */ +export interface OutpostArtifactSuite { + /** Ethereum half of the release suite. */ + readonly ethereum: EthereumOutpostArtifactSuite + /** Solana half of the release suite. */ + readonly solana: SolanaOutpostArtifactSuite +} + +/** One deployment-interface mismatch against a registered artifact suite. */ +export interface OutpostArtifactInterfaceMismatch { + /** Human-readable interface identity. */ + readonly label: string + /** Digest recorded by the deployment profile. */ + readonly actual: string + /** Digest published by the producer artifact package. */ + readonly expected: string +} + +/** Current producer package pair compiled into this SDK branch. */ +export const CurrentOutpostArtifactSuite: OutpostArtifactSuite = { + ethereum: { + manifest: EthereumOutpostArtifactManifest, + factories: { + [EthereumContractName.BAR]: BAR__factory, + [EthereumContractName.OPP]: OPP__factory, + [EthereumContractName.OPPInbound]: OPPInbound__factory, + [EthereumContractName.OperatorRegistry]: OperatorRegistry__factory, + [EthereumContractName.ReserveManager]: ReserveManager__factory + } + }, + solana: { + manifest: SolanaOutpostArtifactManifest, + idls: { + [SolanaProgramName.liqsolCore]: liqsolCoreIdl + } + } +} + +/** Artifact suites supported by this SDK release. */ +const RegisteredOutpostArtifactSuites: readonly OutpostArtifactSuite[] = [ + CurrentOutpostArtifactSuite +] + +/** Return Ethereum interface mismatches for one artifact suite. */ +function ethereumInterfaceMismatches( + profile: OutpostDeploymentProfile, + suite: OutpostArtifactSuite +): OutpostArtifactInterfaceMismatch[] { + return Object.values(EthereumContractName).flatMap(contractName => { + const deployment = profile.ethereum.contracts[contractName] + if (deployment == null) return [] + const expected = suite.ethereum.manifest.contracts[contractName].abiSha256 + return deployment.abiSha256 === expected + ? [] + : [ + { + label: `Ethereum ${contractName} ABI`, + actual: deployment.abiSha256, + expected + } + ] + }) +} + +/** Return Solana interface mismatches for one artifact suite. */ +function solanaInterfaceMismatches( + profile: OutpostDeploymentProfile, + suite: OutpostArtifactSuite +): OutpostArtifactInterfaceMismatch[] { + return Object.values(SolanaProgramName).flatMap(programName => { + const actual = profile.solana.programs[programName].idlSha256, + expected = suite.solana.manifest.programs[programName].idlSha256 + return actual === expected + ? [] + : [ + { + label: `Solana ${programName} IDL`, + actual, + expected + } + ] + }) +} + +/** Return all profile interface mismatches for one family and suite. */ +export function outpostArtifactInterfaceMismatches( + profile: OutpostDeploymentProfile, + family: OutpostChainFamily, + suite: OutpostArtifactSuite +): OutpostArtifactInterfaceMismatch[] { + return match(family) + .with(OutpostChainFamily.ethereum, () => + ethereumInterfaceMismatches(profile, suite) + ) + .with(OutpostChainFamily.solana, () => + solanaInterfaceMismatches(profile, suite) + ) + .exhaustive() +} + +/** Internal registry for producer package suites compiled into this SDK. */ +export namespace OutpostArtifactRegistry { + /** Return suites whose paired generated interfaces match one profile. */ + export function candidates( + profile: OutpostDeploymentProfile + ): readonly OutpostArtifactSuite[] { + return RegisteredOutpostArtifactSuites.filter( + suite => + outpostArtifactInterfaceMismatches( + profile, + OutpostChainFamily.ethereum, + suite + ).length === 0 && + outpostArtifactInterfaceMismatches( + profile, + OutpostChainFamily.solana, + suite + ).length === 0 + ) + } + + /** Resolve paired generated bindings for a compatible deployment profile. */ + export function resolve( + profile: OutpostDeploymentProfile + ): OutpostArtifactSuite { + const suite = candidates(profile)[0] + if (suite == null) { + throw new Error( + `No registered artifact suite matches deployment profile ${profile.id}` + ) + } + return suite + } +} diff --git a/packages/sdk-outpost/src/artifacts/SuiteCompatibility.ts b/packages/sdk-outpost/src/artifacts/SuiteCompatibility.ts new file mode 100644 index 0000000..25cbc21 --- /dev/null +++ b/packages/sdk-outpost/src/artifacts/SuiteCompatibility.ts @@ -0,0 +1,116 @@ +import { getBytes, sha256 as ethersSha256 } from "ethers" + +import { + EthereumContractName, + OutpostChainFamily, + SolanaProgramName, + type OutpostDeploymentProfile +} from "../deployments/index.js" +import { + outpostArtifactInterfaceMismatches, + type OutpostArtifactSuite +} from "./Registry.js" + +const SolanaProgramDataMetadataByteLength = 45 + +/** Deployment-specific byte range in one Ethereum runtime template. */ +interface EthereumRuntimeReference { + readonly start: number + readonly length: number +} + +/** Return the SHA-256 digest for chain runtime bytes. */ +function sha256(value: Uint8Array): string { + return ethersSha256(value).slice(2) +} + +/** Zero environment-specific ranges in live Ethereum runtime code. */ +function normalizeEthereumRuntimeCode( + code: string, + runtimeReferences: readonly EthereumRuntimeReference[] +): Uint8Array { + const runtimeCode = Uint8Array.from(getBytes(code)) + let previousReferenceEnd = 0 + + runtimeReferences.forEach(({ start, length }) => { + const referenceEnd = start + length + if ( + !Number.isInteger(start) || + !Number.isInteger(length) || + length <= 0 || + start < previousReferenceEnd || + referenceEnd > runtimeCode.length + ) { + throw new Error("Ethereum artifact has invalid runtime references") + } + runtimeCode.fill(0, start, referenceEnd) + previousReferenceEnd = referenceEnd + }) + + return runtimeCode +} + +/** Verify live Ethereum code against one producer runtime template. */ +export function assertEthereumRuntimeSuiteCompatibility( + contractName: EthereumContractName, + code: string, + suite: OutpostArtifactSuite +): void { + const artifact = suite.ethereum.manifest.contracts[contractName], + runtimeReferences = [ + ...artifact.runtimeLinkReferences, + ...artifact.runtimeImmutableReferences + ].sort((left, right) => left.start - right.start), + normalizedCode = normalizeEthereumRuntimeCode(code, runtimeReferences) + + if (normalizedCode.length !== artifact.runtimeBytecodeLength) { + throw new Error( + `Ethereum ${contractName} artifact runtime length mismatch: expected ${artifact.runtimeBytecodeLength}, received ${normalizedCode.length}` + ) + } + const digest = sha256(normalizedCode) + if (digest !== artifact.runtimeBytecodeSha256) { + throw new Error( + `Ethereum ${contractName} artifact runtime mismatch: expected ${artifact.runtimeBytecodeSha256}, received ${digest}` + ) + } +} + +/** Verify live Solana executable bytes against one producer program binary. */ +export function assertSolanaProgramSuiteCompatibility( + programName: SolanaProgramName, + programData: Uint8Array, + suite: OutpostArtifactSuite +): void { + const artifact = suite.solana.manifest.programs[programName], + programBinaryEnd = + SolanaProgramDataMetadataByteLength + artifact.programBinaryLength + + if (programData.length < programBinaryEnd) { + throw new Error( + `Solana ${programName} artifact program is truncated: expected ${artifact.programBinaryLength} executable bytes` + ) + } + const digest = sha256( + programData.subarray(SolanaProgramDataMetadataByteLength, programBinaryEnd) + ) + if (digest !== artifact.programBinarySha256) { + throw new Error( + `Solana ${programName} artifact program mismatch: expected ${artifact.programBinarySha256}, received ${digest}` + ) + } +} + +/** Verify profile interfaces against one producer artifact suite. */ +export function assertOutpostArtifactSuiteCompatibility( + profile: OutpostDeploymentProfile, + family: OutpostChainFamily, + suite: OutpostArtifactSuite +): void { + const mismatch = outpostArtifactInterfaceMismatches(profile, family, suite)[0] + if (mismatch != null) { + throw new Error( + `${mismatch.label} interface mismatch: expected ${mismatch.expected}, received ${mismatch.actual}` + ) + } +} diff --git a/packages/sdk-outpost/src/artifacts/index.ts b/packages/sdk-outpost/src/artifacts/index.ts new file mode 100644 index 0000000..2f22aed --- /dev/null +++ b/packages/sdk-outpost/src/artifacts/index.ts @@ -0,0 +1,2 @@ +export * from "./Compatibility.js" +export * from "./Manifests.js" diff --git a/packages/sdk-outpost/src/clients/OutpostClient.ts b/packages/sdk-outpost/src/clients/OutpostClient.ts new file mode 100644 index 0000000..b777e18 --- /dev/null +++ b/packages/sdk-outpost/src/clients/OutpostClient.ts @@ -0,0 +1,12 @@ +import { OutpostClientFactory } from "./OutpostClientFactory.js" +import { OutpostClientFor, OutpostClientInput } from "./Types.js" + +/** Cross-chain facade for creating a verified, family-specific outpost client. */ +export namespace OutpostClient { + /** Create the precise client type selected by the request discriminator. */ + export async function create( + input: T + ): Promise> { + return OutpostClientFactory.create(input) + } +} diff --git a/packages/sdk-outpost/src/clients/OutpostClientFactory.ts b/packages/sdk-outpost/src/clients/OutpostClientFactory.ts new file mode 100644 index 0000000..3d70456 --- /dev/null +++ b/packages/sdk-outpost/src/clients/OutpostClientFactory.ts @@ -0,0 +1,25 @@ +import { match } from "ts-pattern" + +import { OutpostChainFamily } from "../deployments/index.js" +import { EthereumOutpostClient } from "./ethereum/EthereumOutpostClient.js" +import { SolanaOutpostClient } from "./solana/SolanaOutpostClient.js" +import { OutpostClientFor, OutpostClientInput } from "./Types.js" + +/** Internal factory that owns family-specific client construction. */ +export namespace OutpostClientFactory { + /** Create the precise client selected by an outpost-family request. */ + export async function create( + input: T + ): Promise> { + const client = await match(input as OutpostClientInput) + .with({ family: OutpostChainFamily.ethereum }, ({ options }) => + EthereumOutpostClient.create(options) + ) + .with({ family: OutpostChainFamily.solana }, ({ options }) => + SolanaOutpostClient.create(options) + ) + .exhaustive() + + return client as OutpostClientFor + } +} diff --git a/packages/sdk-outpost/src/clients/Types.ts b/packages/sdk-outpost/src/clients/Types.ts new file mode 100644 index 0000000..68713c3 --- /dev/null +++ b/packages/sdk-outpost/src/clients/Types.ts @@ -0,0 +1,43 @@ +import type { EthereumOutpostClientOptions } from "./ethereum/index.js" +import type { EthereumOutpostClient as EthereumOutpostClientImplementation } from "./ethereum/EthereumOutpostClient.js" +import type { SolanaOutpostClientOptions } from "./solana/index.js" +import type { SolanaOutpostClient as SolanaOutpostClientImplementation } from "./solana/SolanaOutpostClient.js" +import { OutpostChainFamily } from "../deployments/index.js" + +/** Verified Ethereum outpost client instance returned by `OutpostClient.create`. */ +export type EthereumOutpostClient = EthereumOutpostClientImplementation + +/** Verified Solana outpost client instance returned by `OutpostClient.create`. */ +export type SolanaOutpostClient = SolanaOutpostClientImplementation + +/** Request for an Ethereum outpost client. */ +export interface EthereumOutpostClientInput { + /** External-chain family discriminator. */ + family: OutpostChainFamily.ethereum + /** Ethereum-specific client options. */ + options: EthereumOutpostClientOptions +} + +/** Request for a Solana outpost client. */ +export interface SolanaOutpostClientInput { + /** External-chain family discriminator. */ + family: OutpostChainFamily.solana + /** Solana-specific client options. */ + options: SolanaOutpostClientOptions +} + +/** Typed request accepted by the cross-chain client facade. */ +export type OutpostClientInput = + | EthereumOutpostClientInput + | SolanaOutpostClientInput + +/** Concrete client types keyed by external-chain family. */ +export interface OutpostClientMap { + /** Ethereum client type. */ + [OutpostChainFamily.ethereum]: EthereumOutpostClient + /** Solana client type. */ + [OutpostChainFamily.solana]: SolanaOutpostClient +} + +/** Client returned for a selected external-chain family. */ +export type OutpostClientFor = OutpostClientMap[T] diff --git a/packages/sdk-outpost/src/clients/ethereum/Connection.ts b/packages/sdk-outpost/src/clients/ethereum/Connection.ts new file mode 100644 index 0000000..1f7537e --- /dev/null +++ b/packages/sdk-outpost/src/clients/ethereum/Connection.ts @@ -0,0 +1,31 @@ +import type { Provider, Signer } from "ethers" + +/** Narrow an ethers v6 connection to a signer without relying on a v5 static guard. */ +export function isEthereumSigner( + connection: Provider | Signer +): connection is Signer { + return ( + "sendTransaction" in connection && + typeof connection.sendTransaction === "function" + ) +} + +/** Return the provider owned directly or through a connected signer. */ +export function ethereumProvider(connection: Provider | Signer): Provider { + if (!isEthereumSigner(connection)) return connection + if (connection.provider == null) { + throw new Error("Ethereum signer must be connected to a provider") + } + return connection.provider +} + +/** Require a signer before an Ethereum operation can open a wallet prompt. */ +export function assertEthereumSigner( + connection: Provider | Signer, + operation: string +): Signer { + if (!isEthereumSigner(connection)) { + throw new Error(`${operation} requires a connected signer.`) + } + return connection +} diff --git a/packages/sdk-outpost/src/clients/ethereum/EthereumNodeOwnerClient.ts b/packages/sdk-outpost/src/clients/ethereum/EthereumNodeOwnerClient.ts new file mode 100644 index 0000000..eaeaf19 --- /dev/null +++ b/packages/sdk-outpost/src/clients/ethereum/EthereumNodeOwnerClient.ts @@ -0,0 +1,256 @@ +import type { BAR } from "@wireio/outpost-ethereum-artifacts" +import { IERC1155__factory } from "@wireio/outpost-ethereum-artifacts" +import { KeyType, type Name, type PublicKey } from "@wireio/sdk-core" +import { + ZeroAddress, + computeAddress, + getAddress, + getBigInt, + getBytes, + hexlify, + type BigNumberish, + type BytesLike, + type EventLog, + type Log, + type Provider, + type Signer +} from "ethers" +import { match } from "ts-pattern" + +import { assertEthereumSigner } from "./Connection.js" + +const ConfirmationCount = 1, + NodeCommittedEventName = "NodeCommitted", + UncompressedPublicKeyByteLength = 65, + UncompressedPublicKeyPrefix = 4 + +/** BAR WireKey numeric variants accepted for node-owner account authority. */ +enum NodeOwnerWireKeyType { + K1 = 1, + R1 = 2, + EM = 4, + ED = 5 +} + +/** WireNodes ERC-1155 token ids accepted by BAR as node-owner tiers. */ +export enum EthereumNodeOwnerTier { + T1 = 1, + T2 = 2, + T3 = 3 +} + +const DefaultNodeOwnerTiers = [ + EthereumNodeOwnerTier.T1, + EthereumNodeOwnerTier.T2, + EthereumNodeOwnerTier.T3 +] as const + +/** One owned WireNodes tier returned from the canonical ERC-1155 contract. */ +export interface EthereumNodeOwnerSlotBalance { + /** WireNodes token id and node-owner tier. */ + tokenId: EthereumNodeOwnerTier + /** Number of units held by the queried owner. */ + balance: bigint + /** Canonical WireNodes contract configured by BAR. */ + tokenContractAddress: string +} + +/** Inputs required to escrow a WireNodes unit and register its owner. */ +export interface EthereumNodeOwnerCommitRequest { + /** WireNodes token id and node-owner tier to commit. */ + tokenId: EthereumNodeOwnerTier + /** Canonical Wire account name to create or register. */ + wireAccountName: Name + /** Wire account owner/active authority. */ + wirePublicKey: PublicKey + /** Uncompressed SEC1 secp256k1 public key belonging to the EVM signer. */ + depositorPublicKey: BytesLike +} + +/** Canonical `NodeCommitted` fields emitted by BAR. */ +export interface EthereumNodeCommittedEvent { + /** EVM owner that committed the token. */ + owner: string + /** WireNodes token id and node-owner tier. */ + tokenId: EthereumNodeOwnerTier + /** Canonical WireNodes contract from which BAR pulled custody. */ + tokenContractAddress: string + /** Wire account submitted for registration. */ + wireAccountName: string +} + +/** Confirmed node-owner registration submission. */ +export interface EthereumNodeOwnerCommitSubmission { + /** BAR commit transaction hash. */ + transactionId: string + /** ERC-1155 approval transaction hash when approval was required. */ + approvalTransactionId?: string + /** Confirmed BAR event proving the submitted registration. */ + committed: EthereumNodeCommittedEvent +} + +/** Node-owner slot reads, approval, and registration for one verified outpost. */ +export class EthereumNodeOwnerClient { + /** Bind node-owner operations to a generated BAR contract. */ + constructor( + private readonly bar: BAR, + private readonly connection: Provider | Signer + ) {} + + /** Resolve the governance-configured canonical WireNodes contract. */ + async canonicalTokenContractAddress(): Promise { + const address = getAddress(await this.bar.wireNodesContract()) + if (address === ZeroAddress) { + throw new Error("BAR has no canonical WireNodes contract configured.") + } + return address + } + + /** Return non-zero WireNodes balances for the requested owner and tiers. */ + async ownedSlots( + owner: string, + tokenIds: readonly EthereumNodeOwnerTier[] = DefaultNodeOwnerTiers + ): Promise { + const normalizedOwner = getAddress(owner), + tokenContractAddress = await this.canonicalTokenContractAddress(), + token = IERC1155__factory.connect(tokenContractAddress, this.connection), + balances = await Promise.all( + tokenIds.map(async tokenId => ({ + tokenId, + balance: getBigInt(await token.balanceOf(normalizedOwner, tokenId)), + tokenContractAddress + })) + ) + + return balances.filter(({ balance }) => balance > 0n) + } + + /** Approve BAR when needed, commit one WireNodes unit, and return its event. */ + async commit( + request: EthereumNodeOwnerCommitRequest + ): Promise { + const signer = this.assertSigner(), + owner = getAddress(await signer.getAddress()), + depositorPublicKey = this.assertDepositorPublicKey( + request.depositorPublicKey, + owner + ), + wireAccountName = this.canonicalAccountName( + request.wireAccountName.toString() + ), + tokenContractAddress = await this.canonicalTokenContractAddress(), + token = IERC1155__factory.connect(tokenContractAddress, signer), + barAddress = await this.bar.getAddress(), + approved = await token.isApprovedForAll(owner, barAddress) + + let approvalTransactionId: string | undefined + if (!approved) { + const approval = await token.setApprovalForAll(barAddress, true) + approvalTransactionId = approval.hash + await approval.wait(ConfirmationCount) + } + + const transaction = await this.bar.commitNode( + request.tokenId, + wireAccountName, + this.wireKey(request.wirePublicKey), + depositorPublicKey + ), + receipt = await transaction.wait(ConfirmationCount) + + return { + transactionId: transaction.hash, + approvalTransactionId, + committed: EthereumNodeOwnerClient.committedEvent(receipt?.logs) + } + } + + /** Normalize the canonical `NodeCommitted` event from a BAR receipt. */ + static committedEvent( + events: readonly (EventLog | Log)[] | undefined + ): EthereumNodeCommittedEvent { + const event = events?.find( + candidate => + "eventName" in candidate && + candidate.eventName === NodeCommittedEventName + ), + arguments_ = event != null && "args" in event ? event.args : undefined, + owner = arguments_?.[0], + tokenId = arguments_?.[1], + tokenContractAddress = arguments_?.[2], + wireAccountName = arguments_?.[3] + + if ( + owner == null || + tokenId == null || + tokenContractAddress == null || + wireAccountName == null + ) { + throw new Error("Confirmed BAR transaction did not emit NodeCommitted.") + } + return { + owner: getAddress(owner), + tokenId: EthereumNodeOwnerClient.nodeOwnerTier(tokenId), + tokenContractAddress: getAddress(tokenContractAddress), + wireAccountName + } + } + + /** Require a connected EVM signer for node-owner writes. */ + private assertSigner(): Signer { + return assertEthereumSigner(this.connection, "Ethereum node-owner commit") + } + + /** Validate the depositor key shape and its relationship to the signer. */ + private assertDepositorPublicKey( + value: BytesLike, + owner: string + ): Uint8Array { + const publicKey = getBytes(value) + if ( + publicKey.length !== UncompressedPublicKeyByteLength || + publicKey[0] !== UncompressedPublicKeyPrefix + ) { + throw new Error( + "depositorPublicKey must be a 65-byte uncompressed SEC1 key." + ) + } + if (getAddress(computeAddress(hexlify(publicKey))) !== owner) { + throw new Error("depositorPublicKey does not belong to the EVM signer.") + } + return publicKey + } + + /** Validate and canonicalize the non-empty Wire account name. */ + private canonicalAccountName(value: string): string { + if (value.length === 0) { + throw new Error("wireAccountName must not be empty.") + } + return value + } + + /** Convert an sdk-core public key into BAR's generated WireKey structure. */ + private wireKey(publicKey: PublicKey) { + const keyType = match(publicKey.type) + .with(KeyType.K1, () => NodeOwnerWireKeyType.K1) + .with(KeyType.R1, () => NodeOwnerWireKeyType.R1) + .with(KeyType.EM, () => NodeOwnerWireKeyType.EM) + .with(KeyType.ED, () => NodeOwnerWireKeyType.ED) + .otherwise(type => { + throw new Error(`${type} is not a node-owner authority key type.`) + }) + + return { keyType, key: publicKey.data.array } + } + + /** Normalize one emitted token id to BAR's supported node-owner tier. */ + private static nodeOwnerTier(value: BigNumberish): EthereumNodeOwnerTier { + return match(Number(value.toString())) + .with(EthereumNodeOwnerTier.T1, () => EthereumNodeOwnerTier.T1) + .with(EthereumNodeOwnerTier.T2, () => EthereumNodeOwnerTier.T2) + .with(EthereumNodeOwnerTier.T3, () => EthereumNodeOwnerTier.T3) + .otherwise(tokenId => { + throw new Error(`NodeCommitted emitted unsupported tier ${tokenId}.`) + }) + } +} diff --git a/packages/sdk-outpost/src/clients/ethereum/EthereumOutpostClient.ts b/packages/sdk-outpost/src/clients/ethereum/EthereumOutpostClient.ts new file mode 100644 index 0000000..418f421 --- /dev/null +++ b/packages/sdk-outpost/src/clients/ethereum/EthereumOutpostClient.ts @@ -0,0 +1,132 @@ +import type { Provider } from "ethers" +import { match } from "ts-pattern" + +import { + OutpostArtifactRegistry, + type OutpostArtifactSuite +} from "../../artifacts/Registry.js" +import { + EthereumContractName, + OutpostChainFamily +} from "../../deployments/index.js" +import { OutpostDeploymentVerifier } from "../../verification/index.js" +import { ethereumProvider } from "./Connection.js" +import { EthereumContractMap, EthereumOutpostClientOptions } from "./Types.js" +import { EthereumReserveClient } from "./EthereumReserveClient.js" +import { EthereumReserveSwapClient } from "./EthereumReserveSwapClient.js" +import { EthereumNodeOwnerClient } from "./EthereumNodeOwnerClient.js" + +/** Strictly typed access to one verified Ethereum outpost deployment. */ +export class EthereumOutpostClient { + /** Create the Ethereum backend for the package-level outpost client facade. */ + static async create( + options: EthereumOutpostClientOptions + ): Promise { + const { connection, profile } = options, + provider = ethereumProvider(connection) + + await OutpostDeploymentVerifier.verify({ + family: OutpostChainFamily.ethereum, + profile, + provider + }) + return new EthereumOutpostClient( + options, + provider, + OutpostArtifactRegistry.resolve(profile) + ) + } + + private constructor( + private readonly options: EthereumOutpostClientOptions, + /** Provider verified against the configured Ethereum chain. */ + readonly provider: Provider, + private readonly artifactSuite: OutpostArtifactSuite + ) { + this.reserves = new EthereumReserveClient( + this.contract(EthereumContractName.ReserveManager), + options.connection + ) + this.swaps = new EthereumReserveSwapClient( + this.contract(EthereumContractName.ReserveManager), + options.connection + ) + if (options.profile.ethereum.contracts[EthereumContractName.BAR] != null) { + this.nodeOwnerClient = new EthereumNodeOwnerClient( + this.contract(EthereumContractName.BAR), + options.connection + ) + } + } + + /** Reserve creation, cancellation, and reads for this verified outpost. */ + readonly reserves: EthereumReserveClient + + /** Reserve-swap writes and balance reads for this verified outpost. */ + readonly swaps: EthereumReserveSwapClient + + private readonly nodeOwnerClient?: EthereumNodeOwnerClient + + /** Node-owner slot reads, approvals, and BAR registration when deployed. */ + get nodeOwners(): EthereumNodeOwnerClient { + if (this.nodeOwnerClient == null) { + throw new Error( + "Ethereum node owners are unavailable because this deployment profile has no BAR identity." + ) + } + return this.nodeOwnerClient + } + + /** Deployment profile used to verify and connect this client. */ + get profile(): EthereumOutpostClientOptions["profile"] { + return this.options.profile + } + + /** Connect a generated contract client by its typed deployment name. */ + contract(name: T): EthereumContractMap[T] { + const { connection, profile } = this.options, + factories = this.artifactSuite.ethereum.factories, + contract = match(name as EthereumContractName) + .with(EthereumContractName.BAR, () => { + const bar = profile.ethereum.contracts[EthereumContractName.BAR] + if (bar == null) { + throw new Error( + "Ethereum node owners are unavailable because this deployment profile has no BAR identity." + ) + } + return factories[EthereumContractName.BAR].connect( + bar.address, + connection + ) + }) + .with(EthereumContractName.OPP, () => + factories[EthereumContractName.OPP].connect( + profile.ethereum.contracts[EthereumContractName.OPP].address, + connection + ) + ) + .with(EthereumContractName.OPPInbound, () => + factories[EthereumContractName.OPPInbound].connect( + profile.ethereum.contracts[EthereumContractName.OPPInbound].address, + connection + ) + ) + .with(EthereumContractName.OperatorRegistry, () => + factories[EthereumContractName.OperatorRegistry].connect( + profile.ethereum.contracts[EthereumContractName.OperatorRegistry] + .address, + connection + ) + ) + .with(EthereumContractName.ReserveManager, () => + factories[EthereumContractName.ReserveManager].connect( + profile.ethereum.contracts[EthereumContractName.ReserveManager] + .address, + connection + ) + ) + .exhaustive() + + return contract as EthereumContractMap[T] + } +} diff --git a/packages/sdk-outpost/src/clients/ethereum/EthereumReserveClient.ts b/packages/sdk-outpost/src/clients/ethereum/EthereumReserveClient.ts new file mode 100644 index 0000000..32622ee --- /dev/null +++ b/packages/sdk-outpost/src/clients/ethereum/EthereumReserveClient.ts @@ -0,0 +1,223 @@ +import type { + ReserveManager, + ReserveManagerLib +} from "@wireio/outpost-ethereum-artifacts" +import { + Contract, + getAddress, + getBigInt, + ZeroAddress, + type Provider, + type Signer +} from "ethers" +import { match } from "ts-pattern" + +import { + assertEthereumReserveCreateRequest, + assertReserveUnsigned64, + OutpostReserveStatus, + type EthereumReserveCreateRequest, + type EthereumReservePermitSignature, + type EthereumReserveRecord, + type OutpostReserveIdentity, + type OutpostReserveSubmission +} from "../../reserves/index.js" +import { assertEthereumSigner } from "./Connection.js" + +const ConfirmationCount = 1, + EthereumLocalReserveStatus = { + pending: 0n, + active: 1n, + cancelled: 2n + } as const, + Erc20Interface = [ + "function allowance(address owner,address spender) view returns (uint256)", + "function approve(address spender,uint256 amount) returns (bool)" + ] + +/** Reserve creation, cancellation, and reads for one verified Ethereum outpost. */ +export class EthereumReserveClient { + /** Bind reserve lifecycle operations to a generated ReserveManager client. */ + constructor( + private readonly reserveManager: ReserveManager, + private readonly connection: Provider | Signer + ) {} + + /** Create a pending native-token reserve. */ + async createNative( + request: EthereumReserveCreateRequest + ): Promise { + assertEthereumReserveCreateRequest(request) + this.assertSigner() + const parameters = this.nativeParameters(request), + overrides = { value: request.externalTokenAmount } + + await this.reserveManager.create_reserve.staticCall( + ...parameters, + overrides + ) + const transaction = await this.reserveManager.create_reserve( + ...parameters, + overrides + ) + await transaction.wait(ConfirmationCount) + return { transactionId: transaction.hash } + } + + /** Approve and create a pending ERC-20 reserve. */ + async createErc20WithApproval( + request: EthereumReserveCreateRequest, + tokenAddress?: string + ): Promise { + assertEthereumReserveCreateRequest(request) + const signer = this.assertSigner(), + owner = await signer.getAddress(), + configuredTokenAddress = await this.reserveManager.tokenAddressesByCode( + request.tokenCode + ) + + if (configuredTokenAddress === ZeroAddress) { + throw new Error( + `No ERC-20 address is configured for tokenCode ${request.tokenCode.toString()}.` + ) + } + if ( + tokenAddress != null && + getAddress(tokenAddress) !== getAddress(configuredTokenAddress) + ) { + throw new Error( + `ERC-20 address ${tokenAddress} does not match the configured route ${configuredTokenAddress}.` + ) + } + + const token = new Contract(configuredTokenAddress, Erc20Interface, signer), + reserveManagerAddress = await this.reserveManager.getAddress(), + allowance = getBigInt( + await token.allowance(owner, reserveManagerAddress) + ) + if (allowance < getBigInt(request.externalTokenAmount)) { + const approval = await token.approve( + reserveManagerAddress, + request.externalTokenAmount + ) + await approval.wait(ConfirmationCount) + } + + const arguments_ = this.createArguments(request) + await this.reserveManager.requestReserveCreateErc20WithApproval.staticCall( + arguments_ + ) + const transaction = + await this.reserveManager.requestReserveCreateErc20WithApproval( + arguments_ + ) + await transaction.wait(ConfirmationCount) + return { transactionId: transaction.hash } + } + + /** Create a pending ERC-20 reserve using an EIP-2612 permit. */ + async createErc20WithPermit( + request: EthereumReserveCreateRequest, + permitSignature: EthereumReservePermitSignature + ): Promise { + assertEthereumReserveCreateRequest(request) + this.assertSigner() + const arguments_ = this.createArguments(request) + + await this.reserveManager.requestReserveCreateErc20WithPermit.staticCall( + arguments_, + permitSignature + ) + const transaction = + await this.reserveManager.requestReserveCreateErc20WithPermit( + arguments_, + permitSignature + ) + await transaction.wait(ConfirmationCount) + return { transactionId: transaction.hash } + } + + /** Request cancellation and refund of a pending reserve. */ + async cancel( + identity: OutpostReserveIdentity + ): Promise { + this.assertSigner() + assertReserveUnsigned64(identity.tokenCode, "tokenCode") + assertReserveUnsigned64(identity.reserveCode, "reserveCode") + const transaction = await this.reserveManager.cancel_create_reserve( + identity.tokenCode, + identity.reserveCode + ) + await transaction.wait(ConfirmationCount) + return { transactionId: transaction.hash } + } + + /** Read and normalize one local ReserveManager record. */ + async get(identity: OutpostReserveIdentity): Promise { + assertReserveUnsigned64(identity.tokenCode, "tokenCode") + assertReserveUnsigned64(identity.reserveCode, "reserveCode") + const reserve = await this.reserveManager.getReserve( + identity.tokenCode, + identity.reserveCode + ) + return { + tokenCode: reserve.tokenCode, + reserveCode: reserve.reserveCode, + externalTokenAmount: reserve.externalTokenAmount, + requestedWireAmount: reserve.requestedWireAmount, + connectorWeightBps: Number(reserve.connectorWeightBps), + status: match(reserve.status) + .with( + EthereumLocalReserveStatus.pending, + () => OutpostReserveStatus.pending + ) + .with( + EthereumLocalReserveStatus.active, + () => OutpostReserveStatus.active + ) + .with( + EthereumLocalReserveStatus.cancelled, + () => OutpostReserveStatus.cancelled + ) + .otherwise(value => { + throw new Error(`Unsupported Ethereum reserve status ${value}.`) + }), + creator: reserve.creator, + exists: reserve.exists + } + } + + private assertSigner(): Signer { + return assertEthereumSigner(this.connection, "Ethereum reserve operation") + } + + private createArguments( + request: EthereumReserveCreateRequest + ): ReserveManagerLib.ReserveCreateArgsStruct { + return { + tokenCode: request.tokenCode, + reserveCode: request.reserveCode, + externalTokenAmount: request.externalTokenAmount, + requestedWireAmount: request.requestedWireAmount, + connectorWeightBps: request.connectorWeightBps, + name: request.name, + description: request.description, + isPrivate: request.isPrivate, + creatorPubKey: request.creatorPubKey + } + } + + private nativeParameters(request: EthereumReserveCreateRequest) { + return [ + request.tokenCode, + request.reserveCode, + request.externalTokenAmount, + request.requestedWireAmount, + request.connectorWeightBps, + request.name, + request.description, + request.isPrivate, + request.creatorPubKey + ] as const + } +} diff --git a/packages/sdk-outpost/src/clients/ethereum/EthereumReserveSwapClient.ts b/packages/sdk-outpost/src/clients/ethereum/EthereumReserveSwapClient.ts new file mode 100644 index 0000000..a712476 --- /dev/null +++ b/packages/sdk-outpost/src/clients/ethereum/EthereumReserveSwapClient.ts @@ -0,0 +1,201 @@ +import type { + ReserveManager, + ReserveManagerLib +} from "@wireio/outpost-ethereum-artifacts" +import { + Contract, + getBigInt, + type BigNumberish, + type EventLog, + type Log, + type Provider, + type Signer +} from "ethers" + +import { + assertReserveSwapRequest, + type ReserveSwapRequest, + type ReserveSwapSubmission +} from "../../reserves/index.js" +import { assertEthereumSigner, ethereumProvider } from "./Connection.js" + +const BasisPointDenominator = 10_000, + ConfirmationCount = 1, + Erc20Interface = [ + "function allowance(address owner,address spender) view returns (uint256)", + "function approve(address spender,uint256 amount) returns (bool)", + "function balanceOf(address owner) view returns (uint256)" + ], + SubmissionGasHeadroomBps = 2_500 + +/** Reserve-swap writes and balance reads for one verified Ethereum outpost. */ +export class EthereumReserveSwapClient { + /** Create an Ethereum reserve-swap workflow bound to a verified deployment. */ + constructor( + private readonly reserveManager: ReserveManager, + private readonly connection: Provider | Signer + ) {} + + /** Escrow native ETH and return the confirmed protocol deposit id. */ + async requestNative( + request: ReserveSwapRequest + ): Promise { + assertReserveSwapRequest(request) + this.assertSigner() + const parameters = this.nativeParameters(request), + overrides = { value: request.sourceAmount } + + await this.reserveManager.requestSwap.staticCall(...parameters, overrides) + const estimatedGas = await this.reserveManager.requestSwap.estimateGas( + ...parameters, + overrides + ), + submissionOverrides = { + ...overrides, + gasLimit: + EthereumReserveSwapClient.addSubmissionGasHeadroom(estimatedGas) + } + + const transaction = await this.reserveManager.requestSwap( + ...parameters, + submissionOverrides + ), + receipt = await transaction.wait(ConfirmationCount) + return { + transactionId: transaction.hash, + sourceRequestId: EthereumReserveSwapClient.parseSourceRequestId( + receipt?.logs + ) + } + } + + /** Approve and escrow an ERC-20 source amount, then return its deposit id. */ + async requestErc20WithApproval( + request: ReserveSwapRequest, + tokenAddress: string + ): Promise { + assertReserveSwapRequest(request) + const signer = this.assertSigner(), + owner = await signer.getAddress(), + token = new Contract(tokenAddress, Erc20Interface, signer), + reserveManagerAddress = await this.reserveManager.getAddress(), + allowance = await token.allowance(owner, reserveManagerAddress) + + await EthereumReserveSwapClient.approvalAmounts( + allowance, + request.sourceAmount + ).reduce>(async (previousApproval, amount) => { + await previousApproval + const approval = await token.approve(reserveManagerAddress, amount) + await approval.wait(ConfirmationCount) + }, Promise.resolve()) + + const arguments_ = this.swapArguments(request) + await this.reserveManager.requestSwapErc20WithApproval.staticCall( + arguments_ + ) + const estimatedGas = + await this.reserveManager.requestSwapErc20WithApproval.estimateGas( + arguments_ + ), + overrides = { + gasLimit: + EthereumReserveSwapClient.addSubmissionGasHeadroom(estimatedGas) + } + const transaction = await this.reserveManager.requestSwapErc20WithApproval( + arguments_, + overrides + ), + receipt = await transaction.wait(ConfirmationCount) + return { + transactionId: transaction.hash, + sourceRequestId: EthereumReserveSwapClient.parseSourceRequestId( + receipt?.logs + ) + } + } + + /** Read the native balance of an Ethereum account. */ + async nativeBalance(address: string): Promise { + return ethereumProvider(this.connection).getBalance(address) + } + + /** Read the ERC-20 balance of an Ethereum account. */ + async erc20Balance(tokenAddress: string, address: string): Promise { + const token = new Contract(tokenAddress, Erc20Interface, this.connection) + return getBigInt(await token.balanceOf(address)) + } + + private assertSigner(): Signer { + return assertEthereumSigner(this.connection, "Ethereum reserve swap") + } + + private nativeParameters(request: ReserveSwapRequest) { + return [ + request.sourceTokenCode, + request.sourceReserveCode, + request.targetChainCode, + request.targetTokenCode, + request.targetReserveCode, + request.targetRecipient, + request.targetAmount, + request.targetToleranceBps + ] as const + } + + private swapArguments( + request: ReserveSwapRequest + ): ReserveManagerLib.SwapArgsStruct { + return { + sourceTokenCode: request.sourceTokenCode, + sourceReserveCode: request.sourceReserveCode, + sourceAmount: request.sourceAmount, + targetChainCode: request.targetChainCode, + targetTokenCode: request.targetTokenCode, + targetReserveCode: request.targetReserveCode, + targetRecipient: request.targetRecipient, + targetAmount: request.targetAmount, + targetToleranceBps: request.targetToleranceBps + } + } + + /** Add bounded headroom to estimates that traverse OPP delegate calls. */ + static addSubmissionGasHeadroom(estimatedGas: BigNumberish): bigint { + const gas = getBigInt(estimatedGas), + denominator = BigInt(BasisPointDenominator) + return ( + (gas * BigInt(BasisPointDenominator + SubmissionGasHeadroomBps) + + denominator - + 1n) / + denominator + ) + } + + /** Return the safe approval sequence for zero-first ERC-20 implementations. */ + static approvalAmounts( + currentAllowance: BigNumberish, + requiredAllowance: BigNumberish + ): readonly bigint[] { + const current = getBigInt(currentAllowance), + required = getBigInt(requiredAllowance) + if (current >= required) return [] + return current === 0n ? [required] : [0n, required] + } + + /** Parse the canonical deposit id emitted by `requestSwap*`. */ + static parseSourceRequestId( + events: readonly (EventLog | Log)[] | undefined + ): bigint { + const event = events?.find( + candidate => + "eventName" in candidate && candidate.eventName === "SwapDeposit" + ), + id = event != null && "args" in event ? event.args[0] : null + if (id == null) { + throw new Error( + "Confirmed Ethereum reserve swap did not emit SwapDeposit." + ) + } + return BigInt(id.toString()) + } +} diff --git a/packages/sdk-outpost/src/clients/ethereum/Types.ts b/packages/sdk-outpost/src/clients/ethereum/Types.ts new file mode 100644 index 0000000..001d764 --- /dev/null +++ b/packages/sdk-outpost/src/clients/ethereum/Types.ts @@ -0,0 +1,33 @@ +import type { + BAR, + OPP, + OPPInbound, + OperatorRegistry, + ReserveManager +} from "@wireio/outpost-ethereum-artifacts" +import type { Provider, Signer } from "ethers" + +import type { OutpostDeploymentProfile } from "../../deployments/index.js" +import { EthereumContractName } from "../../deployments/index.js" + +/** Inputs required to connect an Ethereum outpost client. */ +export interface EthereumOutpostClientOptions { + /** Immutable deployment profile selected from the parent Wire chain. */ + profile: OutpostDeploymentProfile + /** Ethers provider or connected signer for the target Ethereum chain. */ + connection: Provider | Signer +} + +/** Generated contract clients keyed by their deployment identity. */ +export interface EthereumContractMap { + /** Bond and node-owner registration contract. */ + [EthereumContractName.BAR]: BAR + /** Outbound OPP endpoint. */ + [EthereumContractName.OPP]: OPP + /** Inbound OPP endpoint. */ + [EthereumContractName.OPPInbound]: OPPInbound + /** Operator collateral registry. */ + [EthereumContractName.OperatorRegistry]: OperatorRegistry + /** Reserve custody manager. */ + [EthereumContractName.ReserveManager]: ReserveManager +} diff --git a/packages/sdk-outpost/src/clients/ethereum/index.ts b/packages/sdk-outpost/src/clients/ethereum/index.ts new file mode 100644 index 0000000..7f8f194 --- /dev/null +++ b/packages/sdk-outpost/src/clients/ethereum/index.ts @@ -0,0 +1,4 @@ +export * from "./EthereumReserveSwapClient.js" +export * from "./EthereumReserveClient.js" +export * from "./EthereumNodeOwnerClient.js" +export * from "./Types.js" diff --git a/packages/sdk-outpost/src/clients/index.ts b/packages/sdk-outpost/src/clients/index.ts new file mode 100644 index 0000000..70c41f7 --- /dev/null +++ b/packages/sdk-outpost/src/clients/index.ts @@ -0,0 +1,4 @@ +export * from "./ethereum/index.js" +export * from "./OutpostClient.js" +export * from "./solana/index.js" +export * from "./Types.js" diff --git a/packages/sdk-outpost/src/clients/solana/SolanaOutpostClient.ts b/packages/sdk-outpost/src/clients/solana/SolanaOutpostClient.ts new file mode 100644 index 0000000..01f0566 --- /dev/null +++ b/packages/sdk-outpost/src/clients/solana/SolanaOutpostClient.ts @@ -0,0 +1,81 @@ +import { Program } from "@coral-xyz/anchor" +import type { LiqsolCore } from "@wireio/outpost-solana-artifacts" +import { match } from "ts-pattern" + +import { + OutpostArtifactRegistry, + type OutpostArtifactSuite +} from "../../artifacts/Registry.js" +import { + OutpostChainFamily, + SolanaProgramName +} from "../../deployments/index.js" +import { OutpostDeploymentVerifier } from "../../verification/index.js" +import { SolanaOutpostClientOptions, SolanaProgramMap } from "./Types.js" +import { SolanaReserveClient } from "./SolanaReserveClient.js" +import { SolanaReserveSwapClient } from "./SolanaReserveSwapClient.js" + +/** Strictly typed access to one verified Solana outpost deployment. */ +export class SolanaOutpostClient { + /** Create the Solana backend for the package-level outpost client facade. */ + static async create( + options: SolanaOutpostClientOptions + ): Promise { + const { profile, provider } = options + + await OutpostDeploymentVerifier.verify({ + family: OutpostChainFamily.solana, + profile, + connection: provider.connection + }) + return new SolanaOutpostClient( + options, + OutpostArtifactRegistry.resolve(profile) + ) + } + + private readonly liqsolCore: Program + + private constructor( + private readonly options: SolanaOutpostClientOptions, + artifactSuite: OutpostArtifactSuite + ) { + const address = + options.profile.solana.programs[SolanaProgramName.liqsolCore].address + + this.liqsolCore = new Program( + { + ...artifactSuite.solana.idls[SolanaProgramName.liqsolCore], + address + }, + options.provider + ) + this.reserves = new SolanaReserveClient(options.provider, this.liqsolCore) + this.swaps = new SolanaReserveSwapClient(options.provider, this.liqsolCore) + } + + /** Reserve creation, cancellation, and reads for this verified outpost. */ + readonly reserves: SolanaReserveClient + + /** Reserve-swap writes and balance reads for this verified outpost. */ + readonly swaps: SolanaReserveSwapClient + + /** Provider verified against the configured Solana cluster. */ + get provider(): SolanaOutpostClientOptions["provider"] { + return this.options.provider + } + + /** Deployment profile used to verify and connect this client. */ + get profile(): SolanaOutpostClientOptions["profile"] { + return this.options.profile + } + + /** Return a generated Anchor program client by typed deployment name. */ + program(name: T): SolanaProgramMap[T] { + const program = match(name as SolanaProgramName) + .with(SolanaProgramName.liqsolCore, () => this.liqsolCore) + .exhaustive() + + return program as SolanaProgramMap[T] + } +} diff --git a/packages/sdk-outpost/src/clients/solana/SolanaReserveAddresses.ts b/packages/sdk-outpost/src/clients/solana/SolanaReserveAddresses.ts new file mode 100644 index 0000000..464e2ad --- /dev/null +++ b/packages/sdk-outpost/src/clients/solana/SolanaReserveAddresses.ts @@ -0,0 +1,75 @@ +import { BN } from "@coral-xyz/anchor" +import { PublicKey } from "@solana/web3.js" + +import { + assertReserveUnsigned64, + type OutpostReserveIdentity +} from "../../reserves/index.js" + +const SolanaReserveSeed = { + outpostConfig: Buffer.from("outpost_config"), + outboundMessageBuffer: Buffer.from("outbound_message_buffer"), + reserve: Buffer.from("reserve"), + reserveVault: Buffer.from("reserve_vault") + } as const, + Unsigned64ByteLength = 8 + +/** Canonical PDA derivation for Solana reserve lifecycle and swap clients. */ +export class SolanaReserveAddresses { + /** Bind reserve address derivation to one verified program deployment. */ + constructor(private readonly programId: PublicKey) {} + + /** Derive the singleton outpost configuration PDA. */ + outpostConfig(): PublicKey { + return this.derive([SolanaReserveSeed.outpostConfig]) + } + + /** Derive the singleton outbound message-buffer PDA. */ + outboundMessageBuffer(): PublicKey { + return this.derive([SolanaReserveSeed.outboundMessageBuffer]) + } + + /** Derive a reserve account PDA. */ + reserve(identity: OutpostReserveIdentity): PublicKey { + return this.reserveAddress(SolanaReserveSeed.reserve, identity) + } + + /** Derive a reserve custody-vault PDA. */ + reserveVault(identity: OutpostReserveIdentity): PublicKey { + return this.reserveAddress(SolanaReserveSeed.reserveVault, identity) + } + + /** Convert an SDK reserve integer to Anchor's u64 representation. */ + unsigned64( + value: OutpostReserveIdentity["tokenCode"], + field: string + ): BN { + return new BN(assertReserveUnsigned64(value, field).toString()) + } + + private derive(seeds: Buffer[]): PublicKey { + return PublicKey.findProgramAddressSync(seeds, this.programId)[0] + } + + private reserveAddress( + seed: Buffer, + identity: OutpostReserveIdentity + ): PublicKey { + return this.derive([ + seed, + this.unsigned64Seed(identity.tokenCode, "tokenCode"), + this.unsigned64Seed(identity.reserveCode, "reserveCode") + ]) + } + + private unsigned64Seed( + value: OutpostReserveIdentity["tokenCode"], + field: string + ): Buffer { + return this.unsigned64(value, field).toArrayLike( + Buffer, + "le", + Unsigned64ByteLength + ) + } +} diff --git a/packages/sdk-outpost/src/clients/solana/SolanaReserveClient.ts b/packages/sdk-outpost/src/clients/solana/SolanaReserveClient.ts new file mode 100644 index 0000000..d52f8bd --- /dev/null +++ b/packages/sdk-outpost/src/clients/solana/SolanaReserveClient.ts @@ -0,0 +1,258 @@ +import { + type AnchorProvider, + type IdlAccounts, + type Program +} from "@coral-xyz/anchor" +import { + createAssociatedTokenAccountIdempotentInstruction, + getAssociatedTokenAddressSync, + TOKEN_PROGRAM_ID +} from "@solana/spl-token" +import { + PublicKey, + SYSVAR_RENT_PUBKEY, + SystemProgram, + Transaction, + type TransactionInstruction +} from "@solana/web3.js" +import { match } from "ts-pattern" + +import type { LiqsolCore } from "@wireio/outpost-solana-artifacts" +import { + assertReserveCreateDefinition, + assertReserveUnsigned64, + OutpostReserveStatus, + type OutpostReserveIdentity, + type OutpostReserveSubmission, + type SolanaConfiguredReserveToken, + type SolanaReserveCreateRequest, + type SolanaReserveRecord +} from "../../reserves/index.js" +import { SolanaReserveAddresses } from "./SolanaReserveAddresses.js" + +const PublicKeyByteLength = 32, + NativeSolanaDecimals = 9, + NativeTokenMarker = new PublicKey(new Uint8Array(PublicKeyByteLength)) + +type LiqsolCoreAccounts = IdlAccounts + +/** Reserve creation, cancellation, and reads for one verified Solana outpost. */ +export class SolanaReserveClient { + private readonly addresses: SolanaReserveAddresses + + /** Bind reserve lifecycle operations to a generated Anchor program client. */ + constructor( + private readonly provider: AnchorProvider, + private readonly program: Program + ) { + this.addresses = new SolanaReserveAddresses(program.programId) + } + + /** List token routes configured by the deployed outpost. */ + async getConfiguredTokens(): Promise { + const account = await this.provider.connection.getAccountInfo( + this.addresses.outpostConfig() + ) + if (account == null) { + throw new Error("Solana outpost configuration is unavailable.") + } + const config = this.program.coder.accounts.decode< + LiqsolCoreAccounts["outpostConfig"] + >("outpostConfig", account.data), + precisionByTokenCode = new Map( + config.precisionByTokenCode.map(entry => [ + entry.tokenCode.toString(), + entry.decimals + ]) + ) + + return config.tokenAddressesByCode.map(entry => { + const tokenCode = entry.tokenCode.toString(), + isNative = entry.mint.equals(NativeTokenMarker), + configuredDecimals = precisionByTokenCode.get(tokenCode) + if (!isNative && configuredDecimals == null) { + throw new Error( + `Solana reserve token ${tokenCode} has no configured precision.` + ) + } + return { + tokenCode: BigInt(tokenCode), + mint: entry.mint, + isNative, + decimals: isNative ? NativeSolanaDecimals : configuredDecimals + } + }) + } + + /** Derive the local outpost account for one reserve identity. */ + deriveAddress(identity: OutpostReserveIdentity): PublicKey { + return this.addresses.reserve(identity) + } + + /** Build reserve-creation instructions without signing or submitting. */ + async createInstructions( + request: SolanaReserveCreateRequest + ): Promise { + assertReserveCreateDefinition(request) + assertReserveUnsigned64(request.externalTokenAmount, "externalTokenAmount") + if (request.mint.equals(NativeTokenMarker)) { + throw new Error( + "Native SOL reserve creation requires a real placeholder SPL mint and creator token account; the all-zero native marker is configuration metadata only." + ) + } + const creator = this.assertWallet(), + { + creatorTokenAccount = getAssociatedTokenAddressSync( + request.mint, + creator, + false, + TOKEN_PROGRAM_ID + ) + } = request, + createReserve = await this.program.methods + .createReserve( + this.addresses.unsigned64(request.tokenCode, "tokenCode"), + this.addresses.unsigned64(request.reserveCode, "reserveCode"), + this.addresses.unsigned64( + request.externalTokenAmount, + "externalTokenAmount" + ), + this.addresses.unsigned64( + request.requestedWireAmount, + "requestedWireAmount" + ), + request.connectorWeightBps, + request.name, + request.description, + request.isPrivate + ) + .accounts({ + creator, + config: this.addresses.outpostConfig(), + reserve: this.addresses.reserve(request), + reserveVault: this.addresses.reserveVault(request), + mint: request.mint, + creatorAta: creatorTokenAccount, + outboundMessageBuffer: this.addresses.outboundMessageBuffer(), + tokenProgram: TOKEN_PROGRAM_ID, + systemProgram: SystemProgram.programId, + rent: SYSVAR_RENT_PUBKEY + }) + .instruction() + + if ( + request.ensureCreatorTokenAccount === false || + request.creatorTokenAccount != null + ) { + return [createReserve] + } + + return [ + createAssociatedTokenAccountIdempotentInstruction( + creator, + creatorTokenAccount, + creator, + request.mint, + TOKEN_PROGRAM_ID + ), + createReserve + ] + } + + /** Create a pending external reserve. */ + async create( + request: SolanaReserveCreateRequest + ): Promise { + const transactionId = await this.provider.sendAndConfirm( + new Transaction().add(...(await this.createInstructions(request))) + ) + return { transactionId } + } + + /** Build a creator-authorized pending-reserve cancellation instruction. */ + async cancelInstruction( + identity: OutpostReserveIdentity + ): Promise { + const creator = this.assertWallet() + return this.program.methods + .cancelCreateReserve( + this.addresses.unsigned64(identity.tokenCode, "tokenCode"), + this.addresses.unsigned64(identity.reserveCode, "reserveCode") + ) + .accounts({ + creator, + config: this.addresses.outpostConfig(), + reserve: this.addresses.reserve(identity), + outboundMessageBuffer: this.addresses.outboundMessageBuffer() + }) + .instruction() + } + + /** Request cancellation and refund of a pending reserve. */ + async cancel( + identity: OutpostReserveIdentity + ): Promise { + const transactionId = await this.provider.sendAndConfirm( + new Transaction().add(await this.cancelInstruction(identity)) + ) + return { transactionId } + } + + /** Read and normalize one local reserve account. */ + async get( + identity: OutpostReserveIdentity + ): Promise { + const address = this.addresses.reserve(identity), + account = await this.provider.connection.getAccountInfo(address) + if (account == null) return null + const reserve = this.program.coder.accounts.decode< + LiqsolCoreAccounts["reserve"] + >("reserve", account.data) + + return { + address, + vaultAddress: this.addresses.reserveVault(identity), + tokenCode: BigInt(reserve.tokenCode.toString()), + reserveCode: BigInt(reserve.reserveCode.toString()), + externalTokenAmount: BigInt(reserve.externalTokenAmount.toString()), + requestedWireAmount: BigInt(reserve.requestedWireAmount.toString()), + connectorWeightBps: reserve.connectorWeightBps, + status: match(reserve.status) + .when( + status => "pending" in status, + () => OutpostReserveStatus.pending + ) + .when( + status => "active" in status, + () => OutpostReserveStatus.active + ) + .when( + status => "cancelled" in status, + () => OutpostReserveStatus.cancelled + ) + .otherwise(() => { + throw new Error("Unsupported Solana reserve status.") + }), + creator: reserve.creator, + name: this.decodeFixedUtf8(reserve.nameBytes, reserve.nameLen), + description: this.decodeFixedUtf8( + reserve.descriptionBytes, + reserve.descriptionLen + ) + } + } + + private assertWallet(): PublicKey { + const publicKey = this.provider.wallet.publicKey + if (publicKey == null) { + throw new Error("Solana reserve operation requires a connected wallet.") + } + return publicKey + } + + private decodeFixedUtf8(value: ArrayLike, length: number): string { + return new TextDecoder().decode( + Uint8Array.from(value).slice(0, Number(length)) + ) + } +} diff --git a/packages/sdk-outpost/src/clients/solana/SolanaReserveSwapClient.ts b/packages/sdk-outpost/src/clients/solana/SolanaReserveSwapClient.ts new file mode 100644 index 0000000..2622e6d --- /dev/null +++ b/packages/sdk-outpost/src/clients/solana/SolanaReserveSwapClient.ts @@ -0,0 +1,261 @@ +import { type AnchorProvider, type Program } from "@coral-xyz/anchor" +import { + getAssociatedTokenAddressSync, + TOKEN_PROGRAM_ID +} from "@solana/spl-token" +import { + PublicKey, + SystemProgram, + Transaction, + type TransactionInstruction, + type VersionedTransactionResponse +} from "@solana/web3.js" +import type { LiqsolCore } from "@wireio/outpost-solana-artifacts" +import { getBytes } from "ethers" + +import { + assertReserveSwapRequest, + type ReserveSwapRequest, + type ReserveSwapSubmission, + type SolanaSplReserveSwapRequest +} from "../../reserves/index.js" +import { SolanaReserveAddresses } from "./SolanaReserveAddresses.js" + +const ConfirmationCommitment = "confirmed", + ConfirmationPollIntervalMs = 1_500, + SolanaConfirmationStatus = { + confirmed: "confirmed", + finalized: "finalized" + } as const, + SwapDepositLog = /opp_outpost: SwapDeposit id=(\d+)\b/ + +/** Reserve-swap writes and balance reads for one verified Solana outpost. */ +export class SolanaReserveSwapClient { + private readonly addresses: SolanaReserveAddresses + + /** Create a Solana reserve-swap workflow bound to a verified deployment. */ + constructor( + private readonly provider: AnchorProvider, + private readonly program: Program + ) { + this.addresses = new SolanaReserveAddresses(program.programId) + } + + /** Build a native-SOL reserve-swap instruction without signing it. */ + async createNativeInstruction( + request: ReserveSwapRequest + ): Promise { + assertReserveSwapRequest(request) + const user = this.assertWallet() + return this.program.methods + .requestSwap(...this.instructionArguments(request)) + .accounts({ + user, + config: this.addresses.outpostConfig(), + reserve: this.addresses.reserve({ + tokenCode: request.sourceTokenCode, + reserveCode: request.sourceReserveCode + }), + outboundMessageBuffer: this.addresses.outboundMessageBuffer(), + systemProgram: SystemProgram.programId + }) + .instruction() + } + + /** Escrow native SOL and return the confirmed protocol deposit id. */ + async requestNative( + request: ReserveSwapRequest + ): Promise { + return this.submit(await this.createNativeInstruction(request)) + } + + /** Build a classic SPL reserve-swap instruction without signing it. */ + async createSplInstruction( + request: SolanaSplReserveSwapRequest + ): Promise { + assertReserveSwapRequest(request) + const user = this.assertWallet(), + { + userTokenAccount = getAssociatedTokenAddressSync( + request.mint, + user, + false, + TOKEN_PROGRAM_ID + ) + } = request + + return this.program.methods + .requestSwapSpl(...this.instructionArguments(request)) + .accounts({ + user, + config: this.addresses.outpostConfig(), + reserve: this.addresses.reserve({ + tokenCode: request.sourceTokenCode, + reserveCode: request.sourceReserveCode + }), + reserveVault: this.addresses.reserveVault({ + tokenCode: request.sourceTokenCode, + reserveCode: request.sourceReserveCode + }), + mint: request.mint, + userAta: userTokenAccount, + outboundMessageBuffer: this.addresses.outboundMessageBuffer(), + tokenProgram: TOKEN_PROGRAM_ID + }) + .instruction() + } + + /** Escrow a classic SPL token and return the confirmed protocol deposit id. */ + async requestSpl( + request: SolanaSplReserveSwapRequest + ): Promise { + return this.submit(await this.createSplInstruction(request)) + } + + /** Read the native SOL balance of one account. */ + async nativeBalance(owner = this.assertWallet()): Promise { + return BigInt( + await this.provider.connection.getBalance( + owner, + ConfirmationCommitment + ) + ) + } + + /** Read a classic SPL token balance, returning zero when the ATA is absent. */ + async splBalance( + mint: PublicKey, + owner = this.assertWallet() + ): Promise { + const tokenAccount = getAssociatedTokenAddressSync( + mint, + owner, + false, + TOKEN_PROGRAM_ID + ), + account = await this.provider.connection.getAccountInfo( + tokenAccount, + ConfirmationCommitment + ) + if (account == null) return 0n + const balance = await this.provider.connection.getTokenAccountBalance( + tokenAccount, + ConfirmationCommitment + ) + return BigInt(balance.value.amount) + } + + private assertWallet(): PublicKey { + const publicKey = this.provider.wallet.publicKey + if (publicKey == null) { + throw new Error("Solana reserve swap requires a connected wallet.") + } + return publicKey + } + + private instructionArguments(request: ReserveSwapRequest) { + return [ + this.unsigned64(request.sourceTokenCode, "sourceTokenCode"), + this.unsigned64(request.sourceReserveCode, "sourceReserveCode"), + this.unsigned64(request.sourceAmount, "sourceAmount"), + this.unsigned64(request.targetChainCode, "targetChainCode"), + this.unsigned64(request.targetTokenCode, "targetTokenCode"), + this.unsigned64(request.targetReserveCode, "targetReserveCode"), + Buffer.from(getBytes(request.targetRecipient)), + this.unsigned64(request.targetAmount, "targetAmount"), + request.targetToleranceBps + ] as const + } + + private unsigned64( + value: ReserveSwapRequest["sourceTokenCode"], + field: string + ) { + return this.addresses.unsigned64(value, field) + } + + private async submit( + instruction: TransactionInstruction + ): Promise { + const connection = this.provider.connection, + latestBlockhash = await connection.getLatestBlockhash( + ConfirmationCommitment + ), + transaction = new Transaction({ + feePayer: this.assertWallet(), + blockhash: latestBlockhash.blockhash, + lastValidBlockHeight: latestBlockhash.lastValidBlockHeight + }).add(instruction), + signedTransaction = await this.provider.wallet.signTransaction( + transaction + ), + transactionId = await connection.sendRawTransaction( + signedTransaction.serialize(), + { preflightCommitment: ConfirmationCommitment } + ), + confirmedTransaction = await this.waitForConfirmedTransaction( + transactionId, + latestBlockhash.lastValidBlockHeight + ), + sourceRequestId = SolanaReserveSwapClient.parseSourceRequestId( + confirmedTransaction.meta?.logMessages ?? [] + ) + return { transactionId, sourceRequestId } + } + + /** Poll one submitted signature until Solana records success or a terminal error. */ + private async waitForConfirmedTransaction( + transactionId: string, + lastValidBlockHeight: number + ): Promise { + const connection = this.provider.connection, + [statusResponse, blockHeight] = await Promise.all([ + connection.getSignatureStatuses([transactionId], { + searchTransactionHistory: true + }), + connection.getBlockHeight(ConfirmationCommitment) + ]), + status = statusResponse.value[0] + + if (status?.err != null) { + throw new Error( + `Solana reserve swap ${transactionId} failed: ${JSON.stringify(status.err)}` + ) + } + + const confirmed = + status?.confirmationStatus === SolanaConfirmationStatus.confirmed || + status?.confirmationStatus === SolanaConfirmationStatus.finalized + if (confirmed) { + const confirmedTransaction = await connection.getTransaction( + transactionId, + { + commitment: ConfirmationCommitment, + maxSupportedTransactionVersion: 0 + } + ) + if (confirmedTransaction != null) return confirmedTransaction + } else if (status == null && blockHeight > lastValidBlockHeight) { + throw new Error( + `Solana reserve swap ${transactionId} expired before it was recorded on chain.` + ) + } + + await new Promise(resolve => setTimeout(resolve, ConfirmationPollIntervalMs)) + return this.waitForConfirmedTransaction( + transactionId, + lastValidBlockHeight + ) + } + + /** Parse the canonical deposit id logged by `request_swap*`. */ + static parseSourceRequestId(logMessages: readonly string[]): bigint { + const match = logMessages + .map(message => message.match(SwapDepositLog)) + .find(candidate => candidate != null) + if (match == null) { + throw new Error("Confirmed Solana reserve swap did not log SwapDeposit.") + } + return BigInt(match[1]) + } +} diff --git a/packages/sdk-outpost/src/clients/solana/Types.ts b/packages/sdk-outpost/src/clients/solana/Types.ts new file mode 100644 index 0000000..00d258a --- /dev/null +++ b/packages/sdk-outpost/src/clients/solana/Types.ts @@ -0,0 +1,19 @@ +import type { AnchorProvider, Program } from "@coral-xyz/anchor" + +import type { OutpostDeploymentProfile } from "../../deployments/index.js" +import { SolanaProgramName } from "../../deployments/index.js" +import type { LiqsolCore } from "@wireio/outpost-solana-artifacts" + +/** Inputs required to connect a Solana outpost client. */ +export interface SolanaOutpostClientOptions { + /** Immutable deployment profile selected from the parent Wire chain. */ + profile: OutpostDeploymentProfile + /** Anchor provider for the target Solana cluster. */ + provider: AnchorProvider +} + +/** Generated program clients keyed by their deployment identity. */ +export interface SolanaProgramMap { + /** liqSOL core program deployed for this network group. */ + [SolanaProgramName.liqsolCore]: Program +} diff --git a/packages/sdk-outpost/src/clients/solana/index.ts b/packages/sdk-outpost/src/clients/solana/index.ts new file mode 100644 index 0000000..3bd9d25 --- /dev/null +++ b/packages/sdk-outpost/src/clients/solana/index.ts @@ -0,0 +1,4 @@ +export * from "./SolanaReserveSwapClient.js" +export * from "./SolanaReserveAddresses.js" +export * from "./SolanaReserveClient.js" +export * from "./Types.js" diff --git a/packages/sdk-outpost/src/deployments/Schema.ts b/packages/sdk-outpost/src/deployments/Schema.ts new file mode 100644 index 0000000..9a50a12 --- /dev/null +++ b/packages/sdk-outpost/src/deployments/Schema.ts @@ -0,0 +1,101 @@ +import { PublicKey } from "@solana/web3.js" +import { getAddress } from "ethers" +import { z } from "zod" + +import { EthereumContractName, SolanaProgramName } from "./Types.js" + +const Sha256Schema = z.string().regex(/^[0-9a-f]{64}$/), + WireChainIdSchema = z.string().regex(/^[0-9a-f]{64}$/), + EthereumAddressSchema = z.string().transform((value, context) => { + try { + return getAddress(value) + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error) + context.addIssue({ + code: "custom", + message: `Invalid Ethereum address: ${message}` + }) + return z.NEVER + } + }), + SolanaAddressSchema = z.string().transform((value, context) => { + try { + return new PublicKey(value).toBase58() + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error) + context.addIssue({ + code: "custom", + message: `Invalid Solana address: ${message}` + }) + return z.NEVER + } + }) + +/** Compatibility and runtime identity for one deployed Ethereum contract. */ +export const EthereumContractDeploymentProfileSchema = z.object({ + address: EthereumAddressSchema, + implementationAddress: EthereumAddressSchema, + abiSha256: Sha256Schema, + implementationCodeSha256: Sha256Schema +}) + +/** Compatibility and runtime identity for one deployed Solana program. */ +export const SolanaProgramDeploymentProfileSchema = z.object({ + address: SolanaAddressSchema, + programDataAddress: SolanaAddressSchema, + idlSha256: Sha256Schema, + programDataSha256: Sha256Schema +}) + +/** Immutable compatibility profile for one Wire outpost deployment. */ +export const OutpostDeploymentProfileSchema = z + .object({ + schemaVersion: z.literal(1), + id: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/), + deploymentChecksum: Sha256Schema, + wire: z.object({ + chainId: WireChainIdSchema + }), + ethereum: z.object({ + chainId: z.number().int().positive(), + contracts: z.object({ + [EthereumContractName.BAR]: + EthereumContractDeploymentProfileSchema.optional(), + [EthereumContractName.OPP]: EthereumContractDeploymentProfileSchema, + [EthereumContractName.OPPInbound]: + EthereumContractDeploymentProfileSchema, + [EthereumContractName.OperatorRegistry]: + EthereumContractDeploymentProfileSchema, + [EthereumContractName.ReserveManager]: + EthereumContractDeploymentProfileSchema + }) + }), + solana: z.object({ + genesisHash: SolanaAddressSchema, + programs: z.object({ + [SolanaProgramName.liqsolCore]: SolanaProgramDeploymentProfileSchema + }) + }) + }) + .superRefine((profile, context) => { + const expectedId = `${profile.wire.chainId}-${profile.deploymentChecksum.slice(0, 12)}` + if (profile.id !== expectedId) { + context.addIssue({ + code: "custom", + message: `Deployment profile id must be ${expectedId}`, + path: ["id"] + }) + } + }) + +/** Parsed, runtime-safe outpost deployment profile. */ +export type OutpostDeploymentProfile = z.infer< + typeof OutpostDeploymentProfileSchema +> + +/** Validate an untrusted deployment profile at its JSON boundary. */ +export function parseOutpostDeploymentProfile( + value: unknown +): OutpostDeploymentProfile { + return OutpostDeploymentProfileSchema.parse(value) +} diff --git a/packages/sdk-outpost/src/deployments/Types.ts b/packages/sdk-outpost/src/deployments/Types.ts new file mode 100644 index 0000000..33abb78 --- /dev/null +++ b/packages/sdk-outpost/src/deployments/Types.ts @@ -0,0 +1,19 @@ +/** Supported external-chain client families. */ +export enum OutpostChainFamily { + ethereum = "ethereum", + solana = "solana" +} + +/** Ethereum contracts owned by the current outpost deployment. */ +export enum EthereumContractName { + BAR = "BAR", + OPP = "OPP", + OPPInbound = "OPPInbound", + OperatorRegistry = "OperatorRegistry", + ReserveManager = "ReserveManager" +} + +/** Solana programs owned by the current outpost deployment. */ +export enum SolanaProgramName { + liqsolCore = "liqsolCore" +} diff --git a/packages/sdk-outpost/src/deployments/index.ts b/packages/sdk-outpost/src/deployments/index.ts new file mode 100644 index 0000000..29919be --- /dev/null +++ b/packages/sdk-outpost/src/deployments/index.ts @@ -0,0 +1,2 @@ +export * from "./Schema.js" +export * from "./Types.js" diff --git a/packages/sdk-outpost/src/index.ts b/packages/sdk-outpost/src/index.ts new file mode 100644 index 0000000..974408c --- /dev/null +++ b/packages/sdk-outpost/src/index.ts @@ -0,0 +1,5 @@ +export * from "./clients/index.js" +export * from "./artifacts/index.js" +export * from "./deployments/index.js" +export * from "./reserves/index.js" +export * from "./verification/index.js" diff --git a/packages/sdk-outpost/src/reserves/Types.ts b/packages/sdk-outpost/src/reserves/Types.ts new file mode 100644 index 0000000..1595565 --- /dev/null +++ b/packages/sdk-outpost/src/reserves/Types.ts @@ -0,0 +1,155 @@ +import type { PublicKey } from "@solana/web3.js" +import type { ReserveManagerLib } from "@wireio/outpost-ethereum-artifacts" +import type { BigNumberish, BytesLike } from "ethers" + +/** Two-part identity shared by external-chain reserve custody clients. */ +export interface OutpostReserveIdentity { + /** Packed external token slug. */ + tokenCode: BigNumberish + /** Packed reserve discriminator slug. */ + reserveCode: BigNumberish +} + +/** Portable reserve fields validated before any external wallet prompt. */ +export interface ReserveCreateDefinition extends OutpostReserveIdentity { + /** External-chain amount escrowed by the creator. */ + externalTokenAmount: BigNumberish + /** Exact WIRE amount required to activate the reserve. */ + requestedWireAmount: BigNumberish + /** Bancor connector weight in basis points. */ + connectorWeightBps: number + /** User-facing reserve name. */ + name: string + /** User-facing reserve description. */ + description: string + /** Whether Wire restricts routing to a same-owner external counterpart. */ + isPrivate: boolean +} + +/** Ethereum reserve-creation request with the creator's AuthEx public key. */ +export interface EthereumReserveCreateRequest extends ReserveCreateDefinition { + /** Compressed secp256k1 public key that derives to the connected signer. */ + creatorPubKey: BytesLike +} + +/** Confirmed external reserve lifecycle submission. */ +export interface OutpostReserveSubmission { + /** External-chain transaction hash or signature. */ + transactionId: string +} + +/** Chain-neutral lifecycle states exposed by external reserve clients. */ +export enum OutpostReserveStatus { + pending = "pending", + active = "active", + cancelled = "cancelled" +} + +/** Normalized Ethereum ReserveManager record. */ +export interface EthereumReserveRecord { + /** Packed external token slug. */ + tokenCode: bigint + /** Packed reserve discriminator slug. */ + reserveCode: bigint + /** Raw external amount committed at creation. */ + externalTokenAmount: bigint + /** Exact WIRE amount requested from the matcher. */ + requestedWireAmount: bigint + /** Connector weight in basis points. */ + connectorWeightBps: number + /** Local outpost lifecycle state. */ + status: OutpostReserveStatus + /** Ethereum creator address. */ + creator: string + /** Whether the record exists. */ + exists: boolean +} + +/** Solana reserve-creation request for the permissionless outpost path. */ +export interface SolanaReserveCreateRequest extends ReserveCreateDefinition { + /** Custody mint for SPL reserves, or a real placeholder SPL mint for native SOL. */ + mint: PublicKey + /** Creator token-account override. Defaults to the canonical ATA. */ + creatorTokenAccount?: PublicKey + /** Add an idempotent ATA instruction when no override is supplied. */ + ensureCreatorTokenAccount?: boolean +} + +/** Normalized Solana outpost reserve account. */ +export interface SolanaReserveRecord { + /** Reserve account PDA. */ + address: PublicKey + /** Reserve vault PDA. */ + vaultAddress: PublicKey + /** Packed external token slug. */ + tokenCode: bigint + /** Packed reserve discriminator slug. */ + reserveCode: bigint + /** Raw external amount committed at creation. */ + externalTokenAmount: bigint + /** Exact WIRE amount requested from the matcher. */ + requestedWireAmount: bigint + /** Connector weight in basis points. */ + connectorWeightBps: number + /** Local outpost lifecycle state. */ + status: OutpostReserveStatus + /** Solana wallet that created and can cancel the reserve. */ + creator: PublicKey + /** UTF-8 reserve display name. */ + name: string + /** UTF-8 reserve description. */ + description: string +} + +/** Token route configured by the Solana outpost authority. */ +export interface SolanaConfiguredReserveToken { + /** Packed token slug used in reserve instructions. */ + tokenCode: bigint + /** Configured SPL mint, or the all-zero native marker. */ + mint: PublicKey + /** Whether the token route represents native SOL. */ + isNative: boolean + /** Chain-side decimal precision used at the depot boundary. */ + decimals: number +} + +/** Permit signature accepted by Ethereum ReserveManager. */ +export type EthereumReservePermitSignature = ReserveManagerLib.PermitSigStruct + +/** Confirmed source-outpost submission used to correlate a swap with Wire. */ +export interface ReserveSwapSubmission { + /** External-chain transaction signature or hash. */ + transactionId: string + /** Protocol deposit id copied into `sysio.uwrit::uwreqs.source_tx_id`. */ + sourceRequestId: bigint +} + +/** Reserve-swap request shared by Ethereum and Solana outposts. */ +export interface ReserveSwapRequest { + /** Packed source token slug. */ + sourceTokenCode: BigNumberish + /** Packed source reserve discriminator. */ + sourceReserveCode: BigNumberish + /** Escrowed source amount in external-chain base units. */ + sourceAmount: BigNumberish + /** Packed destination chain slug. */ + targetChainCode: BigNumberish + /** Packed destination token slug. */ + targetTokenCode: BigNumberish + /** Packed destination reserve discriminator or WIRE sentinel. */ + targetReserveCode: BigNumberish + /** Recipient bytes interpreted by the destination chain. */ + targetRecipient: BytesLike + /** Current destination quote in depot units. */ + targetAmount: BigNumberish + /** Maximum accepted quote drift in basis points. */ + targetToleranceBps: number +} + +/** Classic SPL source details for a Solana reserve swap. */ +export interface SolanaSplReserveSwapRequest extends ReserveSwapRequest { + /** SPL mint configured for the source token code. */ + mint: PublicKey + /** Optional source token account; the wallet ATA is used when omitted. */ + userTokenAccount?: PublicKey +} diff --git a/packages/sdk-outpost/src/reserves/Validation.ts b/packages/sdk-outpost/src/reserves/Validation.ts new file mode 100644 index 0000000..a0c653f --- /dev/null +++ b/packages/sdk-outpost/src/reserves/Validation.ts @@ -0,0 +1,125 @@ +import { getBigInt, getBytes, isBytesLike, toUtf8Bytes } from "ethers" + +import type { + EthereumReserveCreateRequest, + ReserveCreateDefinition, + ReserveSwapRequest +} from "./Types.js" + +const MaximumUnsigned64 = 18_446_744_073_709_551_615n, + MinimumReserveValue = 1n, + MinimumConnectorWeightBps = 1, + MaximumConnectorWeightBps = 9999, + MinimumToleranceBps = 0, + MaximumToleranceBps = 10_000, + MaximumReserveNameBytes = 64, + MaximumReserveDescriptionBytes = 256, + CompressedSecp256k1PublicKeyBytes = 33, + CompressedSecp256k1PublicKeyPrefix = { + even: 2, + odd: 3 + } as const, + InvalidCompressedSecp256k1PublicKeyMessage = + "creatorPubKey must be a 33-byte compressed secp256k1 public key." + +/** Validate one value against the positive unsigned 64-bit protocol boundary. */ +export function assertReserveUnsigned64( + value: ReserveSwapRequest["sourceTokenCode"], + field: string +): bigint { + let parsed: bigint + try { + parsed = getBigInt(value) + } catch (error: unknown) { + throw new Error(`${field} must be an integer.`, { cause: error }) + } + if (parsed < MinimumReserveValue || parsed > MaximumUnsigned64) { + throw new Error(`${field} must be between 1 and uint64 max.`) + } + return parsed +} + +/** Validate portable reserve-creation fields before opening a wallet prompt. */ +export function assertReserveCreateDefinition( + definition: ReserveCreateDefinition +): void { + assertReserveUnsigned64(definition.tokenCode, "tokenCode") + assertReserveUnsigned64(definition.reserveCode, "reserveCode") + + let externalTokenAmount: bigint + try { + externalTokenAmount = getBigInt(definition.externalTokenAmount) + } catch (error: unknown) { + throw new Error("externalTokenAmount must be an integer.", { cause: error }) + } + if (externalTokenAmount < MinimumReserveValue) { + throw new Error("externalTokenAmount must be greater than zero.") + } + + assertReserveUnsigned64(definition.requestedWireAmount, "requestedWireAmount") + if ( + !Number.isInteger(definition.connectorWeightBps) || + definition.connectorWeightBps < MinimumConnectorWeightBps || + definition.connectorWeightBps > MaximumConnectorWeightBps + ) { + throw new Error( + `connectorWeightBps must be an integer from ${MinimumConnectorWeightBps} to ${MaximumConnectorWeightBps}.` + ) + } + + const nameBytes = toUtf8Bytes(definition.name).length + if (nameBytes === 0 || nameBytes > MaximumReserveNameBytes) { + throw new Error( + `name must contain 1 to ${MaximumReserveNameBytes} UTF-8 bytes.` + ) + } + + const descriptionBytes = toUtf8Bytes(definition.description).length + if (descriptionBytes > MaximumReserveDescriptionBytes) { + throw new Error( + `description must contain at most ${MaximumReserveDescriptionBytes} UTF-8 bytes.` + ) + } +} + +/** Validate Ethereum-specific reserve creation fields. */ +export function assertEthereumReserveCreateRequest( + request: EthereumReserveCreateRequest +): void { + assertReserveCreateDefinition(request) + if (!isBytesLike(request.creatorPubKey)) { + throw new Error(InvalidCompressedSecp256k1PublicKeyMessage) + } + const creatorPublicKey = getBytes(request.creatorPubKey) + if ( + creatorPublicKey.length !== CompressedSecp256k1PublicKeyBytes || + (creatorPublicKey[0] !== CompressedSecp256k1PublicKeyPrefix.even && + creatorPublicKey[0] !== CompressedSecp256k1PublicKeyPrefix.odd) + ) { + throw new Error(InvalidCompressedSecp256k1PublicKeyMessage) + } +} + +/** Validate portable reserve-swap fields before opening a wallet prompt. */ +export function assertReserveSwapRequest(request: ReserveSwapRequest): void { + assertReserveUnsigned64(request.sourceTokenCode, "sourceTokenCode") + assertReserveUnsigned64(request.sourceReserveCode, "sourceReserveCode") + assertReserveUnsigned64(request.sourceAmount, "sourceAmount") + assertReserveUnsigned64(request.targetChainCode, "targetChainCode") + assertReserveUnsigned64(request.targetTokenCode, "targetTokenCode") + assertReserveUnsigned64(request.targetReserveCode, "targetReserveCode") + assertReserveUnsigned64(request.targetAmount, "targetAmount") + + if ( + !Number.isInteger(request.targetToleranceBps) || + request.targetToleranceBps < MinimumToleranceBps || + request.targetToleranceBps > MaximumToleranceBps + ) { + throw new Error( + `targetToleranceBps must be between ${MinimumToleranceBps} and ${MaximumToleranceBps}.` + ) + } + if (getBytes(request.targetRecipient).length === 0) { + throw new Error("targetRecipient is required.") + } +} diff --git a/packages/sdk-outpost/src/reserves/index.ts b/packages/sdk-outpost/src/reserves/index.ts new file mode 100644 index 0000000..421334f --- /dev/null +++ b/packages/sdk-outpost/src/reserves/index.ts @@ -0,0 +1,2 @@ +export * from "./Types.js" +export * from "./Validation.js" diff --git a/packages/sdk-outpost/src/verification/OutpostDeploymentVerifier.ts b/packages/sdk-outpost/src/verification/OutpostDeploymentVerifier.ts new file mode 100644 index 0000000..bc0e941 --- /dev/null +++ b/packages/sdk-outpost/src/verification/OutpostDeploymentVerifier.ts @@ -0,0 +1,289 @@ +import { PublicKey } from "@solana/web3.js" +import type { AccountInfo, Connection } from "@solana/web3.js" +import type { BytesLike, Provider } from "ethers" +import { dataSlice, getAddress, sha256 as ethersSha256 } from "ethers" +import { match } from "ts-pattern" + +import { + CurrentOutpostArtifactSuite, + OutpostArtifactRegistry, + type OutpostArtifactSuite +} from "../artifacts/Registry.js" +import { + assertEthereumRuntimeSuiteCompatibility, + assertOutpostArtifactSuiteCompatibility, + assertSolanaProgramSuiteCompatibility +} from "../artifacts/SuiteCompatibility.js" +import { + EthereumContractName, + OutpostChainFamily, + SolanaProgramName, + type OutpostDeploymentProfile +} from "../deployments/index.js" +import type { OutpostDeploymentVerificationInput } from "./Types.js" + +const EmptyEthereumCode = "0x", + Eip1967ImplementationStorageSlot = + "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc", + Eip1967ImplementationAddressOffset = 12, + UpgradeableLoaderStateTagByteLength = 4, + SolanaPublicKeyByteLength = 32, + UpgradeableLoaderProgramDataAddressEnd = + UpgradeableLoaderStateTagByteLength + SolanaPublicKeyByteLength + +enum UpgradeableLoaderStateTag { + program = 2, + programData = 3 +} + +/** Canonical Solana upgradeable-loader program identity. */ +export const SolanaUpgradeableLoaderProgramId = new PublicKey( + "BPFLoaderUpgradeab1e11111111111111111111111" +) + +function sha256(value: BytesLike): string { + return ethersSha256(value).slice(2) +} + +function upgradeableLoaderStateTag(data: Uint8Array): number { + if (data.byteLength < UpgradeableLoaderStateTagByteLength) { + throw new Error("Solana upgradeable-loader account data is truncated") + } + return new DataView(data.buffer, data.byteOffset, data.byteLength).getUint32( + 0, + true + ) +} + +function assertSolanaUpgradeableLoaderAccount( + account: AccountInfo, + address: string, + label: string +): void { + if (!account.owner.equals(SolanaUpgradeableLoaderProgramId)) { + throw new Error( + `${label} ${address} is not owned by the Solana upgradeable loader` + ) + } +} + +async function verifyEthereum( + profile: OutpostDeploymentProfile, + provider: Provider, + suite: OutpostArtifactSuite +): Promise { + assertOutpostArtifactSuiteCompatibility( + profile, + OutpostChainFamily.ethereum, + suite + ) + + const network = await provider.getNetwork() + if (network.chainId !== BigInt(profile.ethereum.chainId)) { + throw new Error( + `Ethereum chain mismatch: expected ${profile.ethereum.chainId}, received ${network.chainId}` + ) + } + + await Promise.all( + Object.values(EthereumContractName).map(async contractName => { + const contract = profile.ethereum.contracts[contractName] + if (contract == null) return + const proxyCode = await provider.getCode(contract.address) + + if (proxyCode === EmptyEthereumCode) { + throw new Error( + `Ethereum contract ${contractName} is not deployed at ${contract.address}` + ) + } + + const implementationWord = await provider.getStorage( + contract.address, + Eip1967ImplementationStorageSlot + ), + implementationAddress = getAddress( + dataSlice(implementationWord, Eip1967ImplementationAddressOffset) + ) + + if (implementationAddress !== contract.implementationAddress) { + throw new Error( + `Ethereum ${contractName} implementation mismatch: expected ${contract.implementationAddress}, received ${implementationAddress}` + ) + } + + const implementationCode = await provider.getCode(implementationAddress) + if (implementationCode === EmptyEthereumCode) { + throw new Error( + `Ethereum ${contractName} implementation is not deployed at ${implementationAddress}` + ) + } + + const implementationCodeSha256 = sha256(implementationCode) + if (implementationCodeSha256 !== contract.implementationCodeSha256) { + throw new Error( + `Ethereum ${contractName} implementation code mismatch: expected ${contract.implementationCodeSha256}, received ${implementationCodeSha256}` + ) + } + assertEthereumRuntimeSuiteCompatibility( + contractName, + implementationCode, + suite + ) + }) + ) +} + +async function verifySolana( + profile: OutpostDeploymentProfile, + connection: Connection, + suite: OutpostArtifactSuite +): Promise { + assertOutpostArtifactSuiteCompatibility( + profile, + OutpostChainFamily.solana, + suite + ) + + const genesisHash = await connection.getGenesisHash() + if (genesisHash !== profile.solana.genesisHash) { + throw new Error( + `Solana genesis mismatch: expected ${profile.solana.genesisHash}, received ${genesisHash}` + ) + } + + await Promise.all( + Object.values(SolanaProgramName).map(async programName => { + const program = profile.solana.programs[programName], + programAccount = await connection.getAccountInfo( + new PublicKey(program.address) + ) + + if (programAccount == null || !programAccount.executable) { + throw new Error( + `Solana program ${programName} is not executable at ${program.address}` + ) + } + assertSolanaUpgradeableLoaderAccount( + programAccount, + program.address, + `Solana program ${programName}` + ) + + const programStateTag = upgradeableLoaderStateTag(programAccount.data) + if (programStateTag !== UpgradeableLoaderStateTag.program) { + throw new Error( + `Solana program ${programName} has invalid upgradeable-loader state ${programStateTag}` + ) + } + + if ( + programAccount.data.byteLength < UpgradeableLoaderProgramDataAddressEnd + ) { + throw new Error( + `Solana program ${programName} upgradeable-loader data is truncated` + ) + } + + const programDataAddress = new PublicKey( + programAccount.data.subarray( + UpgradeableLoaderStateTagByteLength, + UpgradeableLoaderProgramDataAddressEnd + ) + ).toBase58() + if (programDataAddress !== program.programDataAddress) { + throw new Error( + `Solana ${programName} ProgramData mismatch: expected ${program.programDataAddress}, received ${programDataAddress}` + ) + } + + const programDataAccount = await connection.getAccountInfo( + new PublicKey(programDataAddress) + ) + if (programDataAccount == null) { + throw new Error( + `Solana ${programName} ProgramData is not deployed at ${programDataAddress}` + ) + } + assertSolanaUpgradeableLoaderAccount( + programDataAccount, + programDataAddress, + `Solana ${programName} ProgramData` + ) + + const programDataStateTag = upgradeableLoaderStateTag( + programDataAccount.data + ) + if (programDataStateTag !== UpgradeableLoaderStateTag.programData) { + throw new Error( + `Solana ${programName} ProgramData has invalid upgradeable-loader state ${programDataStateTag}` + ) + } + + const programDataSha256 = sha256(programDataAccount.data) + if (programDataSha256 !== program.programDataSha256) { + throw new Error( + `Solana ${programName} ProgramData mismatch: expected ${program.programDataSha256}, received ${programDataSha256}` + ) + } + assertSolanaProgramSuiteCompatibility( + programName, + programDataAccount.data, + suite + ) + }) + ) +} + +/** Verify one candidate artifact suite against its live external chain. */ +async function verifyOutpostArtifactSuite( + input: OutpostDeploymentVerificationInput, + suite: OutpostArtifactSuite +): Promise { + await match(input) + .with({ family: OutpostChainFamily.ethereum }, value => + verifyEthereum(value.profile, value.provider, suite) + ) + .with({ family: OutpostChainFamily.solana }, value => + verifySolana(value.profile, value.connection, suite) + ) + .exhaustive() +} + +/** Verify compatible suites in registration order until one matches live code. */ +async function verifyOutpostArtifactSuiteCandidates( + input: OutpostDeploymentVerificationInput, + suites: readonly OutpostArtifactSuite[], + index = 0 +): Promise { + const suite = suites[index] + if (suite == null) { + assertOutpostArtifactSuiteCompatibility( + input.profile, + input.family, + CurrentOutpostArtifactSuite + ) + throw new Error( + `No registered artifact suite matches deployment profile ${input.profile.id}` + ) + } + + try { + await verifyOutpostArtifactSuite(input, suite) + } catch (error: unknown) { + if (index + 1 >= suites.length) throw error + await verifyOutpostArtifactSuiteCandidates(input, suites, index + 1) + } +} + +/** Cross-chain facade for exact outpost deployment-profile verification. */ +export namespace OutpostDeploymentVerifier { + /** Verify chain identity, interface compatibility, and exact live runtime identity. */ + export async function verify( + input: OutpostDeploymentVerificationInput + ): Promise { + await verifyOutpostArtifactSuiteCandidates( + input, + OutpostArtifactRegistry.candidates(input.profile) + ) + } +} diff --git a/packages/sdk-outpost/src/verification/Types.ts b/packages/sdk-outpost/src/verification/Types.ts new file mode 100644 index 0000000..2ed775c --- /dev/null +++ b/packages/sdk-outpost/src/verification/Types.ts @@ -0,0 +1,30 @@ +import type { Connection } from "@solana/web3.js" +import type { Provider } from "ethers" + +import type { OutpostDeploymentProfile } from "../deployments/index.js" +import { OutpostChainFamily } from "../deployments/index.js" + +/** Ethereum verification request for an outpost deployment profile. */ +export interface EthereumOutpostDeploymentVerificationInput { + /** External-chain family discriminator. */ + family: OutpostChainFamily.ethereum + /** Immutable deployment profile to verify. */ + profile: OutpostDeploymentProfile + /** Ethereum provider connected to the deployed contracts. */ + provider: Provider +} + +/** Solana verification request for an outpost deployment profile. */ +export interface SolanaOutpostDeploymentVerificationInput { + /** External-chain family discriminator. */ + family: OutpostChainFamily.solana + /** Immutable deployment profile to verify. */ + profile: OutpostDeploymentProfile + /** Solana connection targeting the deployed program. */ + connection: Connection +} + +/** Typed verification input accepted by the cross-chain verifier facade. */ +export type OutpostDeploymentVerificationInput = + | EthereumOutpostDeploymentVerificationInput + | SolanaOutpostDeploymentVerificationInput diff --git a/packages/sdk-outpost/src/verification/index.ts b/packages/sdk-outpost/src/verification/index.ts new file mode 100644 index 0000000..89cba26 --- /dev/null +++ b/packages/sdk-outpost/src/verification/index.ts @@ -0,0 +1,2 @@ +export * from "./OutpostDeploymentVerifier.js" +export * from "./Types.js" diff --git a/packages/sdk-outpost/tests/Fixtures.ts b/packages/sdk-outpost/tests/Fixtures.ts new file mode 100644 index 0000000..9e593dc --- /dev/null +++ b/packages/sdk-outpost/tests/Fixtures.ts @@ -0,0 +1,222 @@ +import { AnchorProvider, Wallet } from "@coral-xyz/anchor" +import { Connection, Keypair, PublicKey } from "@solana/web3.js" +import { readFileSync } from "node:fs" +import { createRequire } from "node:module" +import { + getAddress, + hexlify, + JsonRpcProvider, + Network, + sha256, + toBeHex, + zeroPadValue +} from "ethers" + +import { + EthereumContractName, + OutpostArtifactManifests, + type OutpostDeploymentProfile, + SolanaProgramName, + SolanaUpgradeableLoaderProgramId, + parseOutpostDeploymentProfile +} from "@wireio/sdk-outpost" + +const TestHash = "a".repeat(64), + TestWireChainId = "c".repeat(64), + TestSolanaGenesisHash = "5nBtmutQLrRKBUxNfHJPDjiW5u8id6QM9Hhjg1D1g1XH", + TestSolanaProgramAddress = "4vJ9JU1bJJE96FWSJKvHsmmFADCg4gpZQff4P3bkLKi", + TestSolanaProgramDataAddress = "8qbHbw2BbbTHBW1sbeqakYXVKRQM8Ne7pLK7m6CVfeR", + TestSolanaRpcUrl = "http://example.invalid", + UpgradeableLoaderStateTagByteLength = 4, + SolanaPublicKeyByteLength = 32, + ProgramAccountDataByteLength = + UpgradeableLoaderStateTagByteLength + SolanaPublicKeyByteLength, + SolanaProgramDataMetadataByteLength = 45, + SolanaProgramDataPaddingByteLength = 16, + ProgramStateTag = 2, + ProgramDataStateTag = 3, + TestEthereumProxyAddressBase = 100, + TestEthereumImplementationAddressBase = 200, + TestEthereumProxyCode = "0x01", + PackageRequire = createRequire(__filename), + SolanaProgramArtifact = + OutpostArtifactManifests.solana.programs[SolanaProgramName.liqsolCore], + SolanaProgramBinary = readFileSync( + PackageRequire.resolve( + `${OutpostArtifactManifests.solana.package.name}/${SolanaProgramArtifact.programBinaryPath}` + ) + ) + +/** Create one deterministic Ethereum address for a fixture index. */ +function createEthereumAddress(index: number): string { + return getAddress(zeroPadValue(toBeHex(index), 20)) +} + +/** Create deployment-substituted live code from one producer runtime template. */ +export function createEthereumImplementationCode( + contractName: EthereumContractName +): string { + const artifact = OutpostArtifactManifests.ethereum.contracts[contractName], + runtimeCode = Buffer.from( + readFileSync( + PackageRequire.resolve( + `${OutpostArtifactManifests.ethereum.package.name}/${artifact.runtimeBytecodePath}` + ) + ) + ) + + artifact.runtimeLinkReferences.forEach(({ start, length }) => + runtimeCode.fill(1, start, start + length) + ) + artifact.runtimeImmutableReferences.forEach(({ start, length }) => + runtimeCode.fill(2, start, start + length) + ) + return hexlify(runtimeCode) +} + +/** Encode the upgradeable-loader Program account for one ProgramData address. */ +export function createSolanaProgramAccountData( + programDataAddress: string +): Buffer { + const data = Buffer.alloc(ProgramAccountDataByteLength) + data.writeUInt32LE(ProgramStateTag, 0) + new PublicKey(programDataAddress) + .toBuffer() + .copy(data, UpgradeableLoaderStateTagByteLength) + return data +} + +/** Encode deterministic upgradeable-loader ProgramData account contents. */ +export function createSolanaProgramDataAccountData(): Buffer { + const data = Buffer.alloc( + SolanaProgramDataMetadataByteLength + + SolanaProgramBinary.length + + SolanaProgramDataPaddingByteLength + ) + data.writeUInt32LE(ProgramDataStateTag, 0) + data.writeBigUInt64LE(1n, UpgradeableLoaderStateTagByteLength) + SolanaProgramBinary.copy(data, SolanaProgramDataMetadataByteLength) + return data +} + +/** Create a valid profile aligned with the SDK's source-owned artifacts. */ +export function createOutpostDeploymentProfileFixture(): OutpostDeploymentProfile { + const ethereumContracts = Object.fromEntries( + Object.values(EthereumContractName).map((contractName, index) => [ + contractName, + { + address: createEthereumAddress(TestEthereumProxyAddressBase + index), + implementationAddress: createEthereumAddress( + TestEthereumImplementationAddressBase + index + ), + abiSha256: + OutpostArtifactManifests.ethereum.contracts[contractName].abiSha256, + implementationCodeSha256: sha256( + createEthereumImplementationCode(contractName) + ).slice(2) + } + ]) + ), + programData = createSolanaProgramDataAccountData(), + profile = { + schemaVersion: 1, + id: `${TestWireChainId}-${TestHash.slice(0, 12)}`, + deploymentChecksum: TestHash, + wire: { chainId: TestWireChainId }, + ethereum: { + chainId: 31_337, + contracts: ethereumContracts + }, + solana: { + genesisHash: TestSolanaGenesisHash, + programs: { + [SolanaProgramName.liqsolCore]: { + address: TestSolanaProgramAddress, + programDataAddress: TestSolanaProgramDataAddress, + idlSha256: + OutpostArtifactManifests.solana.programs[ + SolanaProgramName.liqsolCore + ].idlSha256, + programDataSha256: sha256(programData).slice(2) + } + } + } + } + + return parseOutpostDeploymentProfile(profile) +} + +/** Create an Ethereum provider aligned with one deployment profile. */ +export function createEthereumProviderFixture( + profile: OutpostDeploymentProfile +): JsonRpcProvider { + const provider = new JsonRpcProvider() + jest.spyOn(provider, "getNetwork").mockResolvedValue( + Network.from({ + chainId: profile.ethereum.chainId, + name: "wire-outpost" + }) + ) + jest.spyOn(provider, "getCode").mockImplementation(async address => { + const implementation = Object.entries(profile.ethereum.contracts).find( + ([, deployment]) => deployment?.implementationAddress === address + ) + if (implementation != null) { + return createEthereumImplementationCode( + implementation[0] as EthereumContractName + ) + } + return Object.values(profile.ethereum.contracts).some( + deployment => deployment?.address === address + ) + ? TestEthereumProxyCode + : "0x" + }) + jest.spyOn(provider, "getStorage").mockImplementation(async address => { + const contract = Object.values(profile.ethereum.contracts).find( + deployment => deployment?.address === address + ) + return zeroPadValue( + contract?.implementationAddress ?? + profile.ethereum.contracts[EthereumContractName.OPP] + .implementationAddress, + 32 + ) + }) + return provider +} + +/** Create a Solana provider aligned with one deployment profile. */ +export function createSolanaProviderFixture( + profile: OutpostDeploymentProfile +): AnchorProvider { + const connection = new Connection(TestSolanaRpcUrl), + provider = new AnchorProvider(connection, new Wallet(Keypair.generate())), + program = profile.solana.programs[SolanaProgramName.liqsolCore] + + jest + .spyOn(connection, "getGenesisHash") + .mockResolvedValue(profile.solana.genesisHash) + jest.spyOn(connection, "getAccountInfo").mockImplementation(async address => { + if (address.equals(new PublicKey(program.address))) { + return { + data: createSolanaProgramAccountData(program.programDataAddress), + executable: true, + lamports: 1, + owner: SolanaUpgradeableLoaderProgramId, + rentEpoch: 0 + } + } + if (address.equals(new PublicKey(program.programDataAddress))) { + return { + data: createSolanaProgramDataAccountData(), + executable: false, + lamports: 1, + owner: SolanaUpgradeableLoaderProgramId, + rentEpoch: 0 + } + } + return null + }) + return provider +} diff --git a/packages/sdk-outpost/tests/assets/Artifacts.test.ts b/packages/sdk-outpost/tests/assets/Artifacts.test.ts new file mode 100644 index 0000000..1b5ed69 --- /dev/null +++ b/packages/sdk-outpost/tests/assets/Artifacts.test.ts @@ -0,0 +1,165 @@ +import type { Program } from "@coral-xyz/anchor" +import { + BAR__factory, + IERC1155__factory, + OPP__factory, + OperatorRegistry__factory, + ReserveManager__factory +} from "@wireio/outpost-ethereum-artifacts" +import { + liqsolCoreIdl, + type LiqsolCore +} from "@wireio/outpost-solana-artifacts" +import { createHash } from "node:crypto" +import { readFileSync } from "node:fs" +import { createRequire } from "node:module" + +import { + EthereumContractName, + OutpostArtifactManifests, + OutpostChainFamily, + SolanaProgramName, + assertOutpostArtifactCompatibility +} from "@wireio/sdk-outpost" + +import { createOutpostDeploymentProfileFixture } from "../Fixtures.js" + +const PackageRequire = createRequire(__filename) + +/** Return the SHA-256 digest for one installed producer-package file. */ +function installedPackageFileSha256( + packageName: string, + packagePath: string +): string { + return createHash("sha256") + .update( + readFileSync(PackageRequire.resolve(`${packageName}/${packagePath}`)) + ) + .digest("hex") +} + +describe("source-owned outpost artifacts", () => { + it("records exact producer package identity", () => { + expect(OutpostArtifactManifests.ethereum.package.name).toBe( + "@wireio/outpost-ethereum-artifacts" + ) + expect(OutpostArtifactManifests.solana.package.name).toBe( + "@wireio/outpost-solana-artifacts" + ) + expect(OutpostArtifactManifests.ethereum.package.version).toBe("0.3.0") + expect(OutpostArtifactManifests.solana.package.version).toBe("0.3.0") + expect(OutpostArtifactManifests.ethereum.source.revision).toBe( + "b90035b48414267d1b3ca183b88e2118a8c5b16e" + ) + expect(OutpostArtifactManifests.solana.source.revision).toBe( + "217c4d6909cb658cd1bf3bcda570400947ed2893" + ) + }) + + it("ships checksum-valid Ethereum runtimes and Solana program inputs", () => { + Object.values(OutpostArtifactManifests.ethereum.contracts).forEach( + artifact => { + expect( + installedPackageFileSha256( + OutpostArtifactManifests.ethereum.package.name, + artifact.runtimeBytecodePath + ) + ).toBe(artifact.runtimeBytecodeSha256) + } + ) + + const solanaProgram = + OutpostArtifactManifests.solana.programs[SolanaProgramName.liqsolCore] + expect( + installedPackageFileSha256( + OutpostArtifactManifests.solana.package.name, + solanaProgram.idlPath + ) + ).toBe(solanaProgram.idlSha256) + expect( + installedPackageFileSha256( + OutpostArtifactManifests.solana.package.name, + solanaProgram.programBinaryPath + ) + ).toBe(solanaProgram.programBinarySha256) + }) + + it.each(Object.values(OutpostChainFamily))( + "accepts a runtime deployment aligned with %s artifacts", + family => { + expect(() => + assertOutpostArtifactCompatibility( + createOutpostDeploymentProfileFixture(), + family + ) + ).not.toThrow() + } + ) + + it("rejects a runtime deployment with an incompatible Ethereum ABI", () => { + const profile = createOutpostDeploymentProfileFixture() + profile.ethereum.contracts[EthereumContractName.ReserveManager].abiSha256 = + "f".repeat(64) + + expect(() => + assertOutpostArtifactCompatibility(profile, OutpostChainFamily.ethereum) + ).toThrow("Ethereum ReserveManager ABI interface mismatch") + }) + + it("rejects a runtime deployment with an incompatible Solana IDL", () => { + const profile = createOutpostDeploymentProfileFixture() + profile.solana.programs[SolanaProgramName.liqsolCore].idlSha256 = + "f".repeat(64) + + expect(() => + assertOutpostArtifactCompatibility(profile, OutpostChainFamily.solana) + ).toThrow("Solana liqsolCore IDL interface mismatch") + }) + + it("generates the callable swap and collateral surfaces", () => { + const accountNames: Array["account"]> = [ + "outpostConfig", + "reserve" + ] + expect(OPP__factory.abi.length).toBeGreaterThan(0) + expect( + OPP__factory.createInterface().getFunction("addAttestation") + ).toBeDefined() + expect( + ReserveManager__factory.createInterface().getFunction("requestSwap") + ).toBeDefined() + expect( + ReserveManager__factory.createInterface().getFunction( + "requestSwapErc20WithApproval" + ) + ).toBeDefined() + expect( + OperatorRegistry__factory.createInterface().getFunction("deposit") + ).toBeDefined() + expect( + OperatorRegistry__factory.createInterface().getFunction("commit") + ).toBeDefined() + expect( + BAR__factory.createInterface().getFunction("wireNodesContract") + ).toBeDefined() + expect( + BAR__factory.createInterface().getFunction("commitNode") + ).toBeDefined() + expect( + IERC1155__factory.createInterface().getFunction("balanceOf") + ).toBeDefined() + expect( + IERC1155__factory.createInterface().getFunction("setApprovalForAll") + ).toBeDefined() + expect( + liqsolCoreIdl.instructions.map(instruction => instruction.name) + ).toEqual( + expect.arrayContaining([ + "requestSwap", + "requestSwapSpl", + "commitUnderwrite" + ]) + ) + expect(accountNames).toEqual(["outpostConfig", "reserve"]) + }) +}) diff --git a/packages/sdk-outpost/tests/assets/Registry.test.ts b/packages/sdk-outpost/tests/assets/Registry.test.ts new file mode 100644 index 0000000..b3f0024 --- /dev/null +++ b/packages/sdk-outpost/tests/assets/Registry.test.ts @@ -0,0 +1,48 @@ +import { ReserveManager__factory } from "@wireio/outpost-ethereum-artifacts" +import { liqsolCoreIdl } from "@wireio/outpost-solana-artifacts" + +import { + CurrentOutpostArtifactSuite, + OutpostArtifactRegistry +} from "@wireio/sdk-outpost/artifacts/Registry" +import { EthereumContractName, SolanaProgramName } from "@wireio/sdk-outpost" + +import { createOutpostDeploymentProfileFixture } from "../Fixtures.js" + +const MismatchedInterfaceDigest = "f".repeat(64) + +describe("OutpostArtifactRegistry", () => { + it("resolves the exact installed Ethereum and Solana package pair", () => { + const suite = OutpostArtifactRegistry.resolve( + createOutpostDeploymentProfileFixture() + ) + + expect(suite).toBe(CurrentOutpostArtifactSuite) + expect(suite.ethereum.factories[EthereumContractName.ReserveManager]).toBe( + ReserveManager__factory + ) + expect(suite.solana.idls[SolanaProgramName.liqsolCore]).toBe(liqsolCoreIdl) + }) + + it("rejects an unregistered Ethereum interface", () => { + const profile = createOutpostDeploymentProfileFixture() + profile.ethereum.contracts[EthereumContractName.ReserveManager].abiSha256 = + MismatchedInterfaceDigest + + expect(OutpostArtifactRegistry.candidates(profile)).toEqual([]) + expect(() => OutpostArtifactRegistry.resolve(profile)).toThrow( + "No registered artifact suite matches deployment profile" + ) + }) + + it("rejects an unregistered Solana interface", () => { + const profile = createOutpostDeploymentProfileFixture() + profile.solana.programs[SolanaProgramName.liqsolCore].idlSha256 = + MismatchedInterfaceDigest + + expect(OutpostArtifactRegistry.candidates(profile)).toEqual([]) + expect(() => OutpostArtifactRegistry.resolve(profile)).toThrow( + "No registered artifact suite matches deployment profile" + ) + }) +}) diff --git a/packages/sdk-outpost/tests/clients/OutpostClient.test.ts b/packages/sdk-outpost/tests/clients/OutpostClient.test.ts new file mode 100644 index 0000000..7c73b14 --- /dev/null +++ b/packages/sdk-outpost/tests/clients/OutpostClient.test.ts @@ -0,0 +1,43 @@ +import { + OutpostChainFamily, + OutpostClient, + type EthereumOutpostClient, + type SolanaOutpostClient +} from "@wireio/sdk-outpost" +import { + createEthereumProviderFixture, + createOutpostDeploymentProfileFixture, + createSolanaProviderFixture +} from "../Fixtures.js" + +describe("OutpostClient", () => { + it("preserves the precise Ethereum client type", async () => { + const profile = createOutpostDeploymentProfileFixture(), + client = await OutpostClient.create({ + family: OutpostChainFamily.ethereum, + options: { + profile, + connection: createEthereumProviderFixture(profile) + } + }), + typedClient: EthereumOutpostClient = client + + expect(typedClient.profile).toBe(profile) + expect(typedClient.reserves).toBeDefined() + }) + + it("preserves the precise Solana client type", async () => { + const profile = createOutpostDeploymentProfileFixture(), + client = await OutpostClient.create({ + family: OutpostChainFamily.solana, + options: { + profile, + provider: createSolanaProviderFixture(profile) + } + }), + typedClient: SolanaOutpostClient = client + + expect(typedClient.profile).toBe(profile) + expect(typedClient.reserves).toBeDefined() + }) +}) diff --git a/packages/sdk-outpost/tests/clients/ethereum/EthereumNodeOwnerClient.test.ts b/packages/sdk-outpost/tests/clients/ethereum/EthereumNodeOwnerClient.test.ts new file mode 100644 index 0000000..7fa806c --- /dev/null +++ b/packages/sdk-outpost/tests/clients/ethereum/EthereumNodeOwnerClient.test.ts @@ -0,0 +1,188 @@ +import type { BAR, IERC1155 } from "@wireio/outpost-ethereum-artifacts" +import { IERC1155__factory } from "@wireio/outpost-ethereum-artifacts" +import { KeyType, Name, PublicKey } from "@wireio/sdk-core" +import { + JsonRpcProvider, + Wallet, + ZeroAddress, + getBytes, + type EventLog, + type TransactionReceipt, + type TransactionResponse +} from "ethers" + +import { + EthereumNodeOwnerClient, + EthereumNodeOwnerTier +} from "@wireio/sdk-outpost" + +const BarAddress = "0x18E317A7D70d8fBf8e6E893616b52390EbBdb629", + TokenContractAddress = "0x5c74c94173F05dA1720953407cbb920F3DF9f887", + CommitTransactionHash = `0x${"11".repeat(32)}`, + ApprovalTransactionHash = `0x${"22".repeat(32)}`, + TestPrivateKey = `0x${"33".repeat(32)}`, + WireAccountName = "nodeowner", + WireAccount = Name.from(WireAccountName), + TransactionReceiptFixture = { + logs: [], + status: 1 + } as unknown as TransactionReceipt + +/** Create a confirmed transaction fixture with optional parsed logs. */ +function transactionFixture( + hash: string, + logs: readonly EventLog[] = [] +): TransactionResponse { + return { + hash, + wait: jest.fn(async () => ({ ...TransactionReceiptFixture, logs })) + } as unknown as TransactionResponse +} + +/** Create generated BAR and IERC-1155 fixtures for one node-owner flow. */ +function contractFixtures( + approved = true, + tokenContractAddress = TokenContractAddress +) { + const wallet = new Wallet(TestPrivateKey), + committedEvent = { + eventName: "NodeCommitted", + args: [ + wallet.address, + BigInt(EthereumNodeOwnerTier.T2), + TokenContractAddress, + WireAccountName + ] + } as unknown as EventLog, + commitTransaction = transactionFixture(CommitTransactionHash, [ + committedEvent + ]), + approvalTransaction = transactionFixture(ApprovalTransactionHash), + bar = { + target: BarAddress, + getAddress: jest.fn(async () => BarAddress), + wireNodesContract: jest.fn(async () => tokenContractAddress), + commitNode: jest.fn(async () => commitTransaction) + } as unknown as BAR, + token = { + balanceOf: jest.fn(async (_owner: string, tokenId: number) => + BigInt(tokenId === EthereumNodeOwnerTier.T2 ? 1 : 0) + ), + isApprovedForAll: jest.fn(async () => approved), + setApprovalForAll: jest.fn(async () => approvalTransaction) + } as unknown as IERC1155 + + jest.spyOn(IERC1155__factory, "connect").mockReturnValue(token) + return { wallet, bar, token, commitTransaction, approvalTransaction } +} + +/** Return an sdk-core public-key input aligned with the EVM test signer. */ +function wirePublicKey(wallet: Wallet): PublicKey { + return PublicKey.from({ + type: KeyType.K1, + compressed: getBytes(wallet.signingKey.compressedPublicKey) + }) +} + +afterEach(() => jest.restoreAllMocks()) + +describe("EthereumNodeOwnerClient", () => { + it("reads only owned tiers from BAR's canonical WireNodes contract", async () => { + const { wallet, bar, token } = contractFixtures(), + client = new EthereumNodeOwnerClient(bar, wallet) + + await expect(client.ownedSlots(wallet.address)).resolves.toEqual([ + { + tokenId: EthereumNodeOwnerTier.T2, + balance: 1n, + tokenContractAddress: TokenContractAddress + } + ]) + expect(token.balanceOf).toHaveBeenCalledTimes(3) + }) + + it("commits through BAR without an unnecessary approval", async () => { + const { wallet, bar, token, commitTransaction } = contractFixtures(), + client = new EthereumNodeOwnerClient(bar, wallet), + submission = await client.commit({ + tokenId: EthereumNodeOwnerTier.T2, + wireAccountName: WireAccount, + wirePublicKey: wirePublicKey(wallet), + depositorPublicKey: wallet.signingKey.publicKey + }) + + expect(submission).toEqual({ + transactionId: CommitTransactionHash, + approvalTransactionId: undefined, + committed: { + owner: wallet.address, + tokenId: EthereumNodeOwnerTier.T2, + tokenContractAddress: TokenContractAddress, + wireAccountName: WireAccountName + } + }) + expect(token.setApprovalForAll).not.toHaveBeenCalled() + expect(bar.commitNode).toHaveBeenCalledWith( + EthereumNodeOwnerTier.T2, + WireAccountName, + expect.objectContaining({ keyType: 1 }), + getBytes(wallet.signingKey.publicKey) + ) + expect(commitTransaction.wait).toHaveBeenCalledWith(1) + }) + + it("confirms ERC-1155 approval before committing when required", async () => { + const { wallet, bar, token, approvalTransaction } = contractFixtures(false), + client = new EthereumNodeOwnerClient(bar, wallet) + + await expect( + client.commit({ + tokenId: EthereumNodeOwnerTier.T2, + wireAccountName: WireAccount, + wirePublicKey: wirePublicKey(wallet), + depositorPublicKey: wallet.signingKey.publicKey + }) + ).resolves.toEqual( + expect.objectContaining({ + approvalTransactionId: ApprovalTransactionHash + }) + ) + expect(token.setApprovalForAll).toHaveBeenCalledWith(BarAddress, true) + expect(approvalTransaction.wait).toHaveBeenCalledWith(1) + }) + + it("rejects provider-only writes and depositor keys from another signer", async () => { + const { wallet, bar } = contractFixtures(), + providerClient = new EthereumNodeOwnerClient( + bar, + new JsonRpcProvider() + ), + signerClient = new EthereumNodeOwnerClient(bar, wallet), + otherWallet = Wallet.createRandom(), + request = { + tokenId: EthereumNodeOwnerTier.T2, + wireAccountName: WireAccount, + wirePublicKey: wirePublicKey(wallet), + depositorPublicKey: wallet.signingKey.publicKey + } + + await expect(providerClient.commit(request)).rejects.toThrow( + "requires a connected signer" + ) + await expect( + signerClient.commit({ + ...request, + depositorPublicKey: otherWallet.signingKey.publicKey + }) + ).rejects.toThrow("does not belong to the EVM signer") + expect(bar.commitNode).not.toHaveBeenCalled() + }) + + it("fails closed when BAR has no canonical WireNodes contract", async () => { + const { wallet, bar } = contractFixtures(true, ZeroAddress) + + await expect( + new EthereumNodeOwnerClient(bar, wallet).canonicalTokenContractAddress() + ).rejects.toThrow("no canonical WireNodes contract") + }) +}) diff --git a/packages/sdk-outpost/tests/clients/ethereum/EthereumOutpostClient.test.ts b/packages/sdk-outpost/tests/clients/ethereum/EthereumOutpostClient.test.ts new file mode 100644 index 0000000..7ee3a83 --- /dev/null +++ b/packages/sdk-outpost/tests/clients/ethereum/EthereumOutpostClient.test.ts @@ -0,0 +1,224 @@ +import { + getBytes, + hexlify, + Network, + sha256, + Wallet, + zeroPadValue, + type EventLog +} from "ethers" + +import { + EthereumContractName, + EthereumReserveClient, + EthereumReserveSwapClient, + OutpostChainFamily, + OutpostClient, + type EthereumOutpostClient, + type EthereumOutpostClientOptions, + type ReserveSwapRequest +} from "@wireio/sdk-outpost" +import { + createEthereumImplementationCode, + createEthereumProviderFixture, + createOutpostDeploymentProfileFixture +} from "../../Fixtures.js" + +/** Create the Ethereum client through the package's only public facade. */ +function createEthereumClient( + options: EthereumOutpostClientOptions +): Promise { + return OutpostClient.create({ + family: OutpostChainFamily.ethereum, + options + }) +} + +describe("EthereumOutpostClient", () => { + it("submits native reserve swaps with estimated gas headroom", async () => { + const request: ReserveSwapRequest = { + sourceTokenCode: 1, + sourceReserveCode: 2, + sourceAmount: 3, + targetChainCode: 4, + targetTokenCode: 5, + targetReserveCode: 6, + targetRecipient: new Uint8Array([7]), + targetAmount: 8, + targetToleranceBps: 500 + }, + wait = jest.fn().mockResolvedValue({ + logs: [{ eventName: "SwapDeposit", args: [42n] } as unknown as EventLog] + }), + requestSwap = Object.assign( + jest.fn().mockResolvedValue({ hash: "0xabc", wait }), + { + staticCall: jest.fn().mockResolvedValue(null), + estimateGas: jest.fn().mockResolvedValue(100_000n) + } + ), + reserveManager = { + requestSwap + } as unknown as ConstructorParameters< + typeof EthereumReserveSwapClient + >[0], + client = new EthereumReserveSwapClient( + reserveManager, + Wallet.createRandom() + ) + + await expect(client.requestNative(request)).resolves.toEqual({ + transactionId: "0xabc", + sourceRequestId: 42n + }) + expect(requestSwap).toHaveBeenCalledWith( + request.sourceTokenCode, + request.sourceReserveCode, + request.targetChainCode, + request.targetTokenCode, + request.targetReserveCode, + request.targetRecipient, + request.targetAmount, + request.targetToleranceBps, + { + value: request.sourceAmount, + gasLimit: 125_000n + } + ) + expect(wait).toHaveBeenCalledWith(1) + }) + + it("adds 25% gas headroom to reserve swap submissions", () => { + expect(EthereumReserveSwapClient.addSubmissionGasHeadroom(789_767)).toEqual( + 987_209n + ) + expect(EthereumReserveSwapClient.addSubmissionGasHeadroom(1)).toEqual(2n) + }) + + it("resets nonzero ERC-20 allowances before increasing them", () => { + expect(EthereumReserveSwapClient.approvalAmounts(2, 3)).toEqual([0n, 3n]) + expect(EthereumReserveSwapClient.approvalAmounts(0, 3)).toEqual([3n]) + expect(EthereumReserveSwapClient.approvalAmounts(3, 3)).toEqual([]) + }) + + it("verifies a profile and returns a generated contract type", async () => { + const profile = createOutpostDeploymentProfileFixture(), + provider = createEthereumProviderFixture(profile), + client = await createEthereumClient({ + profile, + connection: provider + }), + reserveManager = client.contract(EthereumContractName.ReserveManager) + + expect(reserveManager.target).toBe( + profile.ethereum.contracts[EthereumContractName.ReserveManager].address + ) + expect(client.reserves).toBeInstanceOf(EthereumReserveClient) + expect(client.swaps).toBeInstanceOf(EthereumReserveSwapClient) + expect(provider.getCode).toHaveBeenCalledTimes( + Object.values(EthereumContractName).length * 2 + ) + expect(provider.getStorage).toHaveBeenCalledTimes( + Object.values(EthereumContractName).length + ) + }) + + it("keeps existing Ethereum clients usable when BAR is not deployed", async () => { + const profile = createOutpostDeploymentProfileFixture() + delete profile.ethereum.contracts[EthereumContractName.BAR] + const provider = createEthereumProviderFixture(profile), + client = await createEthereumClient({ + profile, + connection: provider + }) + + expect(client.reserves).toBeInstanceOf(EthereumReserveClient) + expect(client.swaps).toBeInstanceOf(EthereumReserveSwapClient) + expect(() => client.nodeOwners).toThrow("has no BAR identity") + expect(provider.getCode).toHaveBeenCalledTimes( + (Object.values(EthereumContractName).length - 1) * 2 + ) + }) + + it("parses the protocol deposit id from a confirmed receipt", () => { + const events = [ + { eventName: "SwapDeposit", args: [42n] } as unknown as EventLog + ] + + expect(EthereumReserveSwapClient.parseSourceRequestId(events)).toBe(42n) + expect(() => EthereumReserveSwapClient.parseSourceRequestId([])).toThrow( + "did not emit SwapDeposit" + ) + }) + + it("rejects the wrong Ethereum chain", async () => { + const profile = createOutpostDeploymentProfileFixture(), + provider = createEthereumProviderFixture(profile) + jest + .spyOn(provider, "getNetwork") + .mockResolvedValue(Network.from({ chainId: 1, name: "mainnet" })) + + await expect( + createEthereumClient({ profile, connection: provider }) + ).rejects.toThrow("Ethereum chain mismatch") + }) + + it("rejects a configured proxy without bytecode", async () => { + const profile = createOutpostDeploymentProfileFixture(), + provider = createEthereumProviderFixture(profile) + jest.spyOn(provider, "getCode").mockResolvedValue("0x") + + await expect( + createEthereumClient({ profile, connection: provider }) + ).rejects.toThrow("is not deployed") + }) + + it("rejects an implementation address mismatch", async () => { + const profile = createOutpostDeploymentProfileFixture(), + provider = createEthereumProviderFixture(profile) + jest + .spyOn(provider, "getStorage") + .mockResolvedValue(zeroPadValue("0x01", 32)) + + await expect( + createEthereumClient({ profile, connection: provider }) + ).rejects.toThrow("implementation mismatch") + }) + + it("rejects an implementation code mismatch", async () => { + const profile = createOutpostDeploymentProfileFixture(), + provider = createEthereumProviderFixture(profile) + profile.ethereum.contracts[ + EthereumContractName.ReserveManager + ].implementationCodeSha256 = "f".repeat(64) + + await expect( + createEthereumClient({ profile, connection: provider }) + ).rejects.toThrow("implementation code mismatch") + }) + + it("rejects live code from another producer runtime", async () => { + const profile = createOutpostDeploymentProfileFixture(), + contract = profile.ethereum.contracts[EthereumContractName.OPP], + incompatibleCodeBytes = getBytes( + createEthereumImplementationCode(EthereumContractName.OPP) + ) + incompatibleCodeBytes[0] ^= 1 + const incompatibleCode = hexlify(incompatibleCodeBytes), + incompatibleCodeSha256 = sha256(incompatibleCode).slice(2) + contract.implementationCodeSha256 = incompatibleCodeSha256 + const provider = createEthereumProviderFixture(profile), + getCode = (provider.getCode as jest.Mock).getMockImplementation() + jest + .spyOn(provider, "getCode") + .mockImplementation(async address => + address === contract.implementationAddress + ? incompatibleCode + : getCode(address) + ) + + await expect( + createEthereumClient({ profile, connection: provider }) + ).rejects.toThrow("artifact runtime") + }) +}) diff --git a/packages/sdk-outpost/tests/clients/ethereum/EthereumReserveClient.test.ts b/packages/sdk-outpost/tests/clients/ethereum/EthereumReserveClient.test.ts new file mode 100644 index 0000000..c63a11d --- /dev/null +++ b/packages/sdk-outpost/tests/clients/ethereum/EthereumReserveClient.test.ts @@ -0,0 +1,245 @@ +import type { ReserveManager } from "@wireio/outpost-ethereum-artifacts" +import { + AbiCoder, + AbstractSigner, + JsonRpcProvider, + Wallet, + ZeroAddress, + ZeroHash, + type Provider, + type TransactionReceipt, + type TransactionRequest, + type TransactionResponse, + type TypedDataDomain, + type TypedDataField +} from "ethers" + +import { + EthereumReserveClient, + OutpostReserveStatus, + type EthereumReserveCreateRequest +} from "@wireio/sdk-outpost" + +const ReserveTransactionHash = `0x${"11".repeat(32)}`, + ApprovalTransactionHash = `0x${"22".repeat(32)}`, + ReserveManagerAddress = "0x18E317A7D70d8fBf8e6E893616b52390EbBdb629", + TokenAddress = "0x5c74c94173F05dA1720953407cbb920F3DF9f887", + CreatorAddress = "0x7412BC256355ABD22dD53De3a38E8995b5d4c1D1", + TransactionReceiptFixture = { + logs: [], + status: 1 + } as unknown as TransactionReceipt + +const request: EthereumReserveCreateRequest = { + tokenCode: 1, + reserveCode: 2, + externalTokenAmount: 3, + requestedWireAmount: 4, + connectorWeightBps: 5_000, + name: "Private ETH reserve", + description: "Same-owner external routing", + isPrivate: true, + creatorPubKey: `0x02${"11".repeat(32)}` +} + +/** Create one v6 transaction response fixture. */ +function transactionFixture( + hash = ReserveTransactionHash, + wait: TransactionResponse["wait"] = jest.fn( + async (): Promise => TransactionReceiptFixture + ) +): TransactionResponse { + return { hash, wait } as unknown as TransactionResponse +} + +/** Create one callable v6 contract method with a static preflight. */ +function contractMethodFixture(transaction: TransactionResponse) { + return Object.assign( + jest.fn(async () => transaction), + { + staticCall: jest.fn(async (): Promise => undefined) + } + ) +} + +/** Create the ReserveManager contract surface exercised by this client. */ +function reserveManagerFixture(configuredTokenAddress = TokenAddress) { + const transaction = transactionFixture(), + createReserve = contractMethodFixture(transaction), + createErc20WithApproval = contractMethodFixture(transaction), + createErc20WithPermit = contractMethodFixture(transaction), + reserveManager = { + target: ReserveManagerAddress, + getAddress: jest.fn(async () => ReserveManagerAddress), + create_reserve: createReserve, + requestReserveCreateErc20WithApproval: createErc20WithApproval, + requestReserveCreateErc20WithPermit: createErc20WithPermit, + cancel_create_reserve: jest.fn(async () => transaction), + tokenAddressesByCode: jest.fn(async () => configuredTokenAddress), + getReserve: jest.fn(async () => ({ + tokenCode: 1n, + reserveCode: 2n, + externalTokenAmount: 3n, + requestedWireAmount: 4n, + connectorWeightBps: 5_000n, + status: 1n, + creator: CreatorAddress, + exists: true + })) + } as unknown as ReserveManager + + return { reserveManager, transaction } +} + +/** Create a static v6 provider that confirms the mocked ERC-20 transaction. */ +function erc20Provider(): JsonRpcProvider { + const provider = new JsonRpcProvider(undefined, 31_337, { + staticNetwork: true + }) + jest + .spyOn(provider, "getTransactionReceipt") + .mockResolvedValue(TransactionReceiptFixture) + return provider +} + +/** Minimal ethers v6 signer used to observe ERC-20 calls and submissions. */ +class Erc20Signer extends AbstractSigner { + readonly approval = transactionFixture(ApprovalTransactionHash) + + constructor(provider: Provider = erc20Provider()) { + super(provider) + } + + async getAddress(): Promise { + return CreatorAddress + } + + async signMessage(): Promise { + return "0x" + } + + async signTransaction(): Promise { + return "0x" + } + + async signTypedData( + _domain: TypedDataDomain, + _types: Record>, + _value: Record + ): Promise { + return "0x" + } + + connect(provider: Provider): Erc20Signer { + return new Erc20Signer(provider) + } + + async call(): Promise { + return AbiCoder.defaultAbiCoder().encode(["uint256"], [0]) + } + + async sendTransaction( + _transaction: TransactionRequest + ): Promise { + return this.approval + } +} + +describe("EthereumReserveClient", () => { + it("creates a native pending reserve after static preflight", async () => { + const { reserveManager, transaction } = reserveManagerFixture(), + client = new EthereumReserveClient(reserveManager, Wallet.createRandom()) + + await expect(client.createNative(request)).resolves.toEqual({ + transactionId: ReserveTransactionHash + }) + expect(reserveManager.create_reserve.staticCall).toHaveBeenCalledTimes(1) + expect(reserveManager.create_reserve).toHaveBeenCalledTimes(1) + expect(transaction.wait).toHaveBeenCalledWith(1) + }) + + it("approves an ERC-20 before creating its pending reserve", async () => { + const { reserveManager } = reserveManagerFixture(), + signer = new Erc20Signer(), + client = new EthereumReserveClient(reserveManager, signer) + + const submission = await client.createErc20WithApproval( + request, + TokenAddress + ) + expect(submission).toEqual({ transactionId: ReserveTransactionHash }) + expect(signer.provider.getTransactionReceipt).toHaveBeenCalledWith( + ApprovalTransactionHash + ) + expect( + reserveManager.requestReserveCreateErc20WithApproval + ).toHaveBeenCalledTimes(1) + }) + + it("creates an ERC-20 reserve with a supplied permit", async () => { + const { reserveManager } = reserveManagerFixture(), + client = new EthereumReserveClient(reserveManager, Wallet.createRandom()) + + await expect( + client.createErc20WithPermit(request, { + deadline: 100, + v: 27, + r: ZeroHash, + s: ZeroHash + }) + ).resolves.toEqual({ transactionId: ReserveTransactionHash }) + expect( + reserveManager.requestReserveCreateErc20WithPermit.staticCall + ).toHaveBeenCalledTimes(1) + }) + + it("cancels a pending reserve and normalizes active local state", async () => { + const { reserveManager } = reserveManagerFixture(), + client = new EthereumReserveClient(reserveManager, Wallet.createRandom()) + + await expect( + client.cancel({ tokenCode: 1, reserveCode: 2 }) + ).resolves.toEqual({ transactionId: ReserveTransactionHash }) + await expect(client.get({ tokenCode: 1, reserveCode: 2 })).resolves.toEqual( + expect.objectContaining({ + tokenCode: 1n, + reserveCode: 2n, + status: OutpostReserveStatus.active, + creator: CreatorAddress, + exists: true + }) + ) + }) + + it("requires a signer and a configured ERC-20 route", async () => { + const { reserveManager } = reserveManagerFixture(), + provider = new JsonRpcProvider(), + providerClient = new EthereumReserveClient(reserveManager, provider) + + await expect(providerClient.createNative(request)).rejects.toThrow( + "requires a connected signer" + ) + + const { reserveManager: unconfiguredReserveManager } = + reserveManagerFixture(ZeroAddress), + signerClient = new EthereumReserveClient( + unconfiguredReserveManager, + new Erc20Signer() + ) + await expect(signerClient.createErc20WithApproval(request)).rejects.toThrow( + "No ERC-20 address is configured" + ) + }) + + it("rejects an ERC-20 address that differs from the configured route", async () => { + const { reserveManager } = reserveManagerFixture(), + signer = new Erc20Signer(), + client = new EthereumReserveClient(reserveManager, signer), + differentTokenAddress = Wallet.createRandom().address + + await expect( + client.createErc20WithApproval(request, differentTokenAddress) + ).rejects.toThrow("does not match the configured route") + expect(signer.provider.getTransactionReceipt).not.toHaveBeenCalled() + }) +}) diff --git a/packages/sdk-outpost/tests/clients/solana/SolanaOutpostClient.test.ts b/packages/sdk-outpost/tests/clients/solana/SolanaOutpostClient.test.ts new file mode 100644 index 0000000..55319dd --- /dev/null +++ b/packages/sdk-outpost/tests/clients/solana/SolanaOutpostClient.test.ts @@ -0,0 +1,279 @@ +import { Keypair, PublicKey, SystemProgram } from "@solana/web3.js" +import { sha256 } from "ethers" + +import { + OutpostChainFamily, + OutpostClient, + type ReserveSwapRequest, + SolanaProgramName, + SolanaReserveClient, + SolanaReserveSwapClient, + SolanaUpgradeableLoaderProgramId, + type SolanaOutpostClient, + type SolanaOutpostClientOptions +} from "@wireio/sdk-outpost" +import { + createOutpostDeploymentProfileFixture, + createSolanaProgramAccountData, + createSolanaProgramDataAccountData, + createSolanaProviderFixture +} from "../../Fixtures.js" + +const WrongProgramDataAddress = "SysvarRent111111111111111111111111111111111" +const SubmittedSignature = "3".repeat(64) +const SolanaProgramDataMetadataByteLength = 45 + +/** Create the Solana client through the package's only public facade. */ +function createSolanaClient( + options: SolanaOutpostClientOptions +): Promise { + return OutpostClient.create({ + family: OutpostChainFamily.solana, + options + }) +} + +const reserveSwapRequest: ReserveSwapRequest = { + sourceTokenCode: 1, + sourceReserveCode: 2, + sourceAmount: 3, + targetChainCode: 4, + targetTokenCode: 5, + targetReserveCode: 6, + targetRecipient: Uint8Array.from([7]), + targetAmount: 8, + targetToleranceBps: 500 +} + +function unsigned64Seed(value: bigint): Buffer { + const seed = Buffer.alloc(8) + seed.writeBigUInt64LE(value) + return seed +} + +describe("SolanaOutpostClient", () => { + it("verifies a profile and returns its runtime program address", async () => { + const profile = createOutpostDeploymentProfileFixture(), + provider = createSolanaProviderFixture(profile), + client = await createSolanaClient({ profile, provider }), + program = client.program(SolanaProgramName.liqsolCore) + + expect(program.programId.toBase58()).toBe( + profile.solana.programs[SolanaProgramName.liqsolCore].address + ) + expect(client.reserves).toBeInstanceOf(SolanaReserveClient) + expect(client.swaps).toBeInstanceOf(SolanaReserveSwapClient) + }) + + it("parses the protocol deposit id from confirmed program logs", () => { + expect( + SolanaReserveSwapClient.parseSourceRequestId([ + "Program log: opp_outpost: SwapDeposit id=42 hash=abc" + ]) + ).toBe(42n) + expect(() => SolanaReserveSwapClient.parseSourceRequestId([])).toThrow( + "did not log SwapDeposit" + ) + }) + + it("derives native reserve accounts from the deployed program seeds", async () => { + const profile = createOutpostDeploymentProfileFixture(), + provider = createSolanaProviderFixture(profile), + client = await createSolanaClient({ profile, provider }), + instruction = await client.swaps.createNativeInstruction( + reserveSwapRequest + ), + programAddress = new PublicKey( + profile.solana.programs[SolanaProgramName.liqsolCore].address + ), + expectedReserve = PublicKey.findProgramAddressSync( + [ + Buffer.from("reserve"), + unsigned64Seed(1n), + unsigned64Seed(2n) + ], + programAddress + )[0] + + expect(instruction.keys[2].pubkey.equals(expectedReserve)).toBe(true) + }) + + it("derives SPL reserve vaults from the deployed program seeds", async () => { + const profile = createOutpostDeploymentProfileFixture(), + provider = createSolanaProviderFixture(profile), + client = await createSolanaClient({ profile, provider }), + instruction = await client.swaps.createSplInstruction({ + ...reserveSwapRequest, + mint: Keypair.generate().publicKey + }), + programAddress = new PublicKey( + profile.solana.programs[SolanaProgramName.liqsolCore].address + ), + expectedVault = PublicKey.findProgramAddressSync( + [ + Buffer.from("reserve_vault"), + unsigned64Seed(1n), + unsigned64Seed(2n) + ], + programAddress + )[0] + + expect(instruction.keys[3].pubkey.equals(expectedVault)).toBe(true) + }) + + it("polls a submitted reserve swap until Solana confirms it", async () => { + const profile = createOutpostDeploymentProfileFixture(), + provider = createSolanaProviderFixture(profile), + client = await createSolanaClient({ profile, provider }), + instruction = SystemProgram.transfer({ + fromPubkey: provider.wallet.publicKey, + toPubkey: provider.wallet.publicKey, + lamports: 1 + }) + jest.spyOn(client.swaps, "createNativeInstruction").mockResolvedValue(instruction) + jest.spyOn(provider.connection, "getLatestBlockhash").mockResolvedValue({ + blockhash: Keypair.generate().publicKey.toBase58(), + lastValidBlockHeight: 100 + }) + jest.spyOn(provider.connection, "sendRawTransaction").mockResolvedValue(SubmittedSignature) + jest.spyOn(provider.connection, "getSignatureStatuses").mockResolvedValue({ + context: { slot: 10 }, + value: [{ slot: 10, confirmations: 1, err: null, confirmationStatus: "confirmed" }] + }) + jest.spyOn(provider.connection, "getBlockHeight").mockResolvedValue(10) + jest.spyOn(provider.connection, "getTransaction").mockResolvedValue({ + meta: { logMessages: ["Program log: opp_outpost: SwapDeposit id=42 hash=abc"] } + } as never) + + await expect(client.swaps.requestNative(reserveSwapRequest)).resolves.toEqual({ + transactionId: SubmittedSignature, + sourceRequestId: 42n + }) + }) + + it("reports an explicit on-chain reserve swap failure", async () => { + const profile = createOutpostDeploymentProfileFixture(), + provider = createSolanaProviderFixture(profile), + client = await createSolanaClient({ profile, provider }), + instruction = SystemProgram.transfer({ + fromPubkey: provider.wallet.publicKey, + toPubkey: provider.wallet.publicKey, + lamports: 1 + }) + jest.spyOn(client.swaps, "createNativeInstruction").mockResolvedValue(instruction) + jest.spyOn(provider.connection, "getLatestBlockhash").mockResolvedValue({ + blockhash: Keypair.generate().publicKey.toBase58(), + lastValidBlockHeight: 100 + }) + jest.spyOn(provider.connection, "sendRawTransaction").mockResolvedValue(SubmittedSignature) + jest.spyOn(provider.connection, "getSignatureStatuses").mockResolvedValue({ + context: { slot: 10 }, + value: [{ slot: 10, confirmations: 1, err: { InstructionError: [2, "Custom"] }, confirmationStatus: "confirmed" }] + }) + jest.spyOn(provider.connection, "getBlockHeight").mockResolvedValue(10) + + await expect(client.swaps.requestNative(reserveSwapRequest)).rejects.toThrow( + `Solana reserve swap ${SubmittedSignature} failed` + ) + }) + + it("rejects the wrong Solana cluster", async () => { + const profile = createOutpostDeploymentProfileFixture(), + provider = createSolanaProviderFixture(profile) + jest + .spyOn(provider.connection, "getGenesisHash") + .mockResolvedValue("9".repeat(32)) + + await expect( + createSolanaClient({ profile, provider }) + ).rejects.toThrow("Solana genesis mismatch") + }) + + it("rejects a configured program that is not executable", async () => { + const profile = createOutpostDeploymentProfileFixture(), + provider = createSolanaProviderFixture(profile) + jest.spyOn(provider.connection, "getAccountInfo").mockResolvedValue(null) + + await expect( + createSolanaClient({ profile, provider }) + ).rejects.toThrow("is not executable") + }) + + it("rejects a Program account pointing at another ProgramData account", async () => { + const profile = createOutpostDeploymentProfileFixture(), + provider = createSolanaProviderFixture(profile) + jest.spyOn(provider.connection, "getAccountInfo").mockResolvedValue({ + data: createSolanaProgramAccountData(WrongProgramDataAddress), + executable: true, + lamports: 1, + owner: SolanaUpgradeableLoaderProgramId, + rentEpoch: 0 + }) + + await expect( + createSolanaClient({ profile, provider }) + ).rejects.toThrow("ProgramData mismatch") + }) + + it("rejects a ProgramData code mismatch", async () => { + const profile = createOutpostDeploymentProfileFixture(), + provider = createSolanaProviderFixture(profile), + program = profile.solana.programs[SolanaProgramName.liqsolCore] + program.programDataSha256 = "f".repeat(64) + jest + .spyOn(provider.connection, "getAccountInfo") + .mockImplementation(async address => + address.equals(new PublicKey(program.address)) + ? { + data: createSolanaProgramAccountData(program.programDataAddress), + executable: true, + lamports: 1, + owner: SolanaUpgradeableLoaderProgramId, + rentEpoch: 0 + } + : { + data: createSolanaProgramDataAccountData(), + executable: false, + lamports: 1, + owner: SolanaUpgradeableLoaderProgramId, + rentEpoch: 0 + } + ) + + await expect( + createSolanaClient({ profile, provider }) + ).rejects.toThrow("ProgramData mismatch") + }) + + it("rejects ProgramData executable bytes from another producer binary", async () => { + const profile = createOutpostDeploymentProfileFixture(), + program = profile.solana.programs[SolanaProgramName.liqsolCore], + incompatibleProgramData = createSolanaProgramDataAccountData() + incompatibleProgramData[SolanaProgramDataMetadataByteLength] ^= 1 + program.programDataSha256 = sha256(incompatibleProgramData).slice(2) + const provider = createSolanaProviderFixture(profile) + jest + .spyOn(provider.connection, "getAccountInfo") + .mockImplementation(async address => + address.equals(new PublicKey(program.address)) + ? { + data: createSolanaProgramAccountData(program.programDataAddress), + executable: true, + lamports: 1, + owner: SolanaUpgradeableLoaderProgramId, + rentEpoch: 0 + } + : { + data: incompatibleProgramData, + executable: false, + lamports: 1, + owner: SolanaUpgradeableLoaderProgramId, + rentEpoch: 0 + } + ) + + await expect( + createSolanaClient({ profile, provider }) + ).rejects.toThrow("artifact program mismatch") + }) +}) diff --git a/packages/sdk-outpost/tests/clients/solana/SolanaReserveAddresses.test.ts b/packages/sdk-outpost/tests/clients/solana/SolanaReserveAddresses.test.ts new file mode 100644 index 0000000..83d0a95 --- /dev/null +++ b/packages/sdk-outpost/tests/clients/solana/SolanaReserveAddresses.test.ts @@ -0,0 +1,66 @@ +import { Keypair, PublicKey } from "@solana/web3.js" + +import { SolanaReserveAddresses } from "@wireio/sdk-outpost" + +const Unsigned64ByteLength = 8 + +function unsigned64Seed(value: bigint): Buffer { + const seed = Buffer.alloc(Unsigned64ByteLength) + seed.writeBigUInt64LE(value) + return seed +} + +describe("SolanaReserveAddresses", () => { + it("derives every reserve lifecycle PDA from the deployed program", () => { + const programId = Keypair.generate().publicKey, + addresses = new SolanaReserveAddresses(programId), + identity = { tokenCode: 1n, reserveCode: 2n }, + expectedReserve = PublicKey.findProgramAddressSync( + [ + Buffer.from("reserve"), + unsigned64Seed(1n), + unsigned64Seed(2n) + ], + programId + )[0], + expectedVault = PublicKey.findProgramAddressSync( + [ + Buffer.from("reserve_vault"), + unsigned64Seed(1n), + unsigned64Seed(2n) + ], + programId + )[0] + + expect(addresses.reserve(identity).equals(expectedReserve)).toBe(true) + expect(addresses.reserveVault(identity).equals(expectedVault)).toBe(true) + expect( + addresses + .outpostConfig() + .equals( + PublicKey.findProgramAddressSync( + [Buffer.from("outpost_config")], + programId + )[0] + ) + ).toBe(true) + expect( + addresses + .outboundMessageBuffer() + .equals( + PublicKey.findProgramAddressSync( + [Buffer.from("outbound_message_buffer")], + programId + )[0] + ) + ).toBe(true) + }) + + it("rejects reserve identity values outside the protocol u64 range", () => { + const addresses = new SolanaReserveAddresses(Keypair.generate().publicKey) + + expect(() => addresses.unsigned64(0, "tokenCode")).toThrow( + "tokenCode must be between 1 and uint64 max." + ) + }) +}) diff --git a/packages/sdk-outpost/tests/clients/solana/SolanaReserveClient.test.ts b/packages/sdk-outpost/tests/clients/solana/SolanaReserveClient.test.ts new file mode 100644 index 0000000..fd11618 --- /dev/null +++ b/packages/sdk-outpost/tests/clients/solana/SolanaReserveClient.test.ts @@ -0,0 +1,190 @@ +import { BN, Program } from "@coral-xyz/anchor" +import { + liqsolCoreIdl, + type LiqsolCore +} from "@wireio/outpost-solana-artifacts" +import { ASSOCIATED_TOKEN_PROGRAM_ID } from "@solana/spl-token" +import { Keypair, PublicKey } from "@solana/web3.js" + +import { + OutpostReserveStatus, + type SolanaReserveCreateRequest, + SolanaReserveClient +} from "@wireio/sdk-outpost" +import { + createOutpostDeploymentProfileFixture, + createSolanaProviderFixture +} from "../../Fixtures.js" + +const SubmittedSignature = "4".repeat(64), + AccountData = Buffer.alloc(8), + NativeTokenMarker = new PublicKey(new Uint8Array(32)) + +const request: SolanaReserveCreateRequest = { + tokenCode: 1, + reserveCode: 2, + externalTokenAmount: 3, + requestedWireAmount: 4, + connectorWeightBps: 5_000, + name: "Private USDC reserve", + description: "Same-owner external routing", + isPrivate: true, + mint: Keypair.generate().publicKey +} + +function clientFixture() { + const profile = createOutpostDeploymentProfileFixture(), + provider = createSolanaProviderFixture(profile), + address = profile.solana.programs.liqsolCore.address, + program = new Program( + { ...liqsolCoreIdl, address }, + provider + ), + client = new SolanaReserveClient(provider, program) + + return { client, program, provider } +} + +describe("SolanaReserveClient", () => { + it("builds permissionless reserve creation with an idempotent creator ATA", async () => { + const { client, program, provider } = clientFixture(), + instructions = await client.createInstructions(request) + + expect(instructions).toHaveLength(2) + expect( + instructions[0].programId.equals(ASSOCIATED_TOKEN_PROGRAM_ID) + ).toBe(true) + expect(instructions[1].programId.equals(program.programId)).toBe(true) + expect( + instructions[1].keys.some(key => + key.pubkey.equals(provider.wallet.publicKey) + ) + ).toBe(true) + }) + + it("uses an explicit creator token account without adding an ATA instruction", async () => { + const { client } = clientFixture(), + instructions = await client.createInstructions({ + ...request, + creatorTokenAccount: Keypair.generate().publicKey + }) + + expect(instructions).toHaveLength(1) + }) + + it("submits reserve creation and pending cancellation through the provider", async () => { + const { client, provider } = clientFixture(), + sendAndConfirm = jest + .spyOn(provider, "sendAndConfirm") + .mockResolvedValue(SubmittedSignature) + + await expect(client.create(request)).resolves.toEqual({ + transactionId: SubmittedSignature + }) + await expect( + client.cancel({ tokenCode: 1, reserveCode: 2 }) + ).resolves.toEqual({ transactionId: SubmittedSignature }) + expect(sendAndConfirm).toHaveBeenCalledTimes(2) + }) + + it("normalizes configured token routes and a local reserve record", async () => { + const { client, program, provider } = clientFixture(), + splMint = Keypair.generate().publicKey, + creator = Keypair.generate().publicKey + + jest.spyOn(provider.connection, "getAccountInfo").mockResolvedValue({ + data: AccountData, + executable: false, + lamports: 1, + owner: program.programId, + rentEpoch: 0 + }) + jest + .spyOn(program.coder.accounts, "decode") + .mockImplementation(accountName => + (accountName === "outpostConfig" + ? { + tokenAddressesByCode: [ + { tokenCode: new BN(1), mint: NativeTokenMarker }, + { tokenCode: new BN(2), mint: splMint } + ], + precisionByTokenCode: [ + { tokenCode: new BN(2), decimals: 6 } + ] + } + : { + tokenCode: new BN(2), + reserveCode: new BN(3), + externalTokenAmount: new BN(4), + requestedWireAmount: new BN(5), + connectorWeightBps: 5_000, + status: { active: {} }, + creator, + nameLen: 4, + nameBytes: [...Buffer.from("USDC"), ...new Uint8Array(60)], + descriptionLen: 7, + descriptionBytes: [ + ...Buffer.from("Private"), + ...new Uint8Array(249) + ] + }) as never + ) + + await expect(client.getConfiguredTokens()).resolves.toEqual([ + { + tokenCode: 1n, + mint: NativeTokenMarker, + isNative: true, + decimals: 9 + }, + { tokenCode: 2n, mint: splMint, isNative: false, decimals: 6 } + ]) + await expect( + client.get({ tokenCode: 2, reserveCode: 3 }) + ).resolves.toEqual( + expect.objectContaining({ + tokenCode: 2n, + reserveCode: 3n, + status: OutpostReserveStatus.active, + creator, + name: "USDC", + description: "Private" + }) + ) + }) + + it("returns null for an absent account and rejects invalid creation values", async () => { + const { client, provider } = clientFixture() + jest.spyOn(provider.connection, "getAccountInfo").mockResolvedValue(null) + + await expect( + client.get({ tokenCode: 1, reserveCode: 2 }) + ).resolves.toBeNull() + await expect( + client.createInstructions({ ...request, externalTokenAmount: 0 }) + ).rejects.toThrow("externalTokenAmount must be greater than zero.") + await expect( + client.createInstructions({ ...request, mint: NativeTokenMarker }) + ).rejects.toThrow("requires a real placeholder SPL mint") + }) + + it("rejects a configured SPL reserve route without chain precision", async () => { + const { client, program, provider } = clientFixture(), + splMint = Keypair.generate().publicKey + jest.spyOn(provider.connection, "getAccountInfo").mockResolvedValue({ + data: AccountData, + executable: false, + lamports: 1, + owner: program.programId, + rentEpoch: 0 + }) + jest.spyOn(program.coder.accounts, "decode").mockReturnValue({ + tokenAddressesByCode: [{ tokenCode: new BN(2), mint: splMint }], + precisionByTokenCode: [] + } as never) + + await expect(client.getConfiguredTokens()).rejects.toThrow( + "Solana reserve token 2 has no configured precision." + ) + }) +}) diff --git a/packages/sdk-outpost/tests/deployments/Schema.test.ts b/packages/sdk-outpost/tests/deployments/Schema.test.ts new file mode 100644 index 0000000..868ea69 --- /dev/null +++ b/packages/sdk-outpost/tests/deployments/Schema.test.ts @@ -0,0 +1,63 @@ +import { + EthereumContractName, + parseOutpostDeploymentProfile +} from "@wireio/sdk-outpost" +import { createOutpostDeploymentProfileFixture } from "../Fixtures.js" + +describe("OutpostDeploymentProfileSchema", () => { + it("parses a valid profile with its Wire chain identity", () => { + const profile = parseOutpostDeploymentProfile( + createOutpostDeploymentProfileFixture() + ) + + expect(profile.id).toBe( + `${profile.wire.chainId}-${profile.deploymentChecksum.slice(0, 12)}` + ) + expect( + profile.ethereum.contracts[EthereumContractName.ReserveManager].address + ).toBe( + createOutpostDeploymentProfileFixture().ethereum.contracts[ + EthereumContractName.ReserveManager + ].address + ) + }) + + it("rejects an invalid contract address", () => { + const fixture = createOutpostDeploymentProfileFixture() + fixture.ethereum.contracts.OPP.address = "not-an-address" + + expect(() => parseOutpostDeploymentProfile(fixture)).toThrow( + "Invalid Ethereum address" + ) + }) + + it("preserves schema-v1 profiles when BAR is not deployed", () => { + const profile = createOutpostDeploymentProfileFixture() + delete profile.ethereum.contracts[EthereumContractName.BAR] + + expect( + parseOutpostDeploymentProfile(profile).ethereum.contracts[ + EthereumContractName.BAR + ] + ).toBeUndefined() + }) + + it("rejects an invalid Solana ProgramData address", () => { + const fixture = createOutpostDeploymentProfileFixture() + fixture.solana.programs.liqsolCore.programDataAddress = + "not-a-program-data-address" + + expect(() => parseOutpostDeploymentProfile(fixture)).toThrow( + "Invalid Solana address" + ) + }) + + it("rejects an environment-specific profile id", () => { + const fixture = createOutpostDeploymentProfileFixture() + fixture.id = "named-environment" + + expect(() => parseOutpostDeploymentProfile(fixture)).toThrow( + "Deployment profile id must be" + ) + }) +}) diff --git a/packages/sdk-outpost/tests/reserves/Validation.test.ts b/packages/sdk-outpost/tests/reserves/Validation.test.ts new file mode 100644 index 0000000..781fed4 --- /dev/null +++ b/packages/sdk-outpost/tests/reserves/Validation.test.ts @@ -0,0 +1,97 @@ +import { + assertEthereumReserveCreateRequest, + assertReserveCreateDefinition, + assertReserveSwapRequest, + assertReserveUnsigned64, + type EthereumReserveCreateRequest, + type ReserveCreateDefinition, + type ReserveSwapRequest +} from "@wireio/sdk-outpost" + +const InvalidConnectorWeights = [0, 10_000] + +const reserveDefinition: ReserveCreateDefinition = { + tokenCode: 1, + reserveCode: 2, + externalTokenAmount: 3, + requestedWireAmount: 4, + connectorWeightBps: 5_000, + name: "Private ETH reserve", + description: "Same-owner external routing", + isPrivate: true +} + +const ethereumRequest: EthereumReserveCreateRequest = { + ...reserveDefinition, + creatorPubKey: `0x02${"11".repeat(32)}` +} + +const request: ReserveSwapRequest = { + sourceTokenCode: 1, + sourceReserveCode: 2, + sourceAmount: 3, + targetChainCode: 4, + targetTokenCode: 5, + targetReserveCode: 6, + targetRecipient: Uint8Array.from([7]), + targetAmount: 8, + targetToleranceBps: 500 +} + +describe("reserve swap validation", () => { + it("accepts portable and Ethereum reserve creation requests", () => { + expect(() => assertReserveCreateDefinition(reserveDefinition)).not.toThrow() + expect(() => + assertEthereumReserveCreateRequest(ethereumRequest) + ).not.toThrow() + }) + + it("rejects invalid reserve creation metadata and creator keys", () => { + InvalidConnectorWeights.forEach(connectorWeightBps => + expect(() => + assertReserveCreateDefinition({ + ...reserveDefinition, + connectorWeightBps + }) + ).toThrow("connectorWeightBps") + ) + expect(() => + assertReserveCreateDefinition({ ...reserveDefinition, name: "" }) + ).toThrow("name must contain") + expect(() => + assertEthereumReserveCreateRequest({ + ...ethereumRequest, + creatorPubKey: "0x02" + }) + ).toThrow("33-byte compressed") + expect(() => + assertEthereumReserveCreateRequest({ + ...ethereumRequest, + creatorPubKey: `0x04${"11".repeat(32)}` + }) + ).toThrow("33-byte compressed") + expect(() => + assertEthereumReserveCreateRequest({ + ...ethereumRequest, + creatorPubKey: "not-hex" + }) + ).toThrow("33-byte compressed") + }) + + it("accepts a portable positive request", () => { + expect(() => assertReserveSwapRequest(request)).not.toThrow() + expect(assertReserveUnsigned64(8, "value")).toBe(8n) + }) + + it("rejects an empty recipient and values outside uint64", () => { + expect(() => + assertReserveSwapRequest({ + ...request, + targetRecipient: new Uint8Array() + }) + ).toThrow("targetRecipient is required") + expect(() => assertReserveUnsigned64(0, "value")).toThrow( + "between 1 and uint64 max" + ) + }) +}) diff --git a/packages/sdk-outpost/tests/verification/OutpostDeploymentVerifier.test.ts b/packages/sdk-outpost/tests/verification/OutpostDeploymentVerifier.test.ts new file mode 100644 index 0000000..a3e0c5d --- /dev/null +++ b/packages/sdk-outpost/tests/verification/OutpostDeploymentVerifier.test.ts @@ -0,0 +1,43 @@ +import { + OutpostChainFamily, + OutpostDeploymentVerifier, + SolanaUpgradeableLoaderProgramId +} from "@wireio/sdk-outpost" +import { + createEthereumProviderFixture, + createOutpostDeploymentProfileFixture, + createSolanaProviderFixture +} from "../Fixtures.js" + +describe("OutpostDeploymentVerifier", () => { + it("verifies an Ethereum deployment through the generic facade", async () => { + const profile = createOutpostDeploymentProfileFixture() + + await expect( + OutpostDeploymentVerifier.verify({ + family: OutpostChainFamily.ethereum, + profile, + provider: createEthereumProviderFixture(profile) + }) + ).resolves.toBeUndefined() + }) + + it("verifies a Solana deployment through the generic facade", async () => { + const profile = createOutpostDeploymentProfileFixture(), + provider = createSolanaProviderFixture(profile) + + await expect( + OutpostDeploymentVerifier.verify({ + family: OutpostChainFamily.solana, + profile, + connection: provider.connection + }) + ).resolves.toBeUndefined() + }) + + it("exports the canonical Solana upgradeable-loader identity", () => { + expect(SolanaUpgradeableLoaderProgramId.toBase58()).toBe( + "BPFLoaderUpgradeab1e11111111111111111111111" + ) + }) +}) diff --git a/packages/sdk-outpost/tsconfig.cjs.jest.json b/packages/sdk-outpost/tsconfig.cjs.jest.json new file mode 100644 index 0000000..9a5a5f8 --- /dev/null +++ b/packages/sdk-outpost/tsconfig.cjs.jest.json @@ -0,0 +1,20 @@ +{ + "extends": "../../etc/tsconfig/tsconfig.base.jest.json", + "compilerOptions": { + "rootDir": "tests", + "outDir": "lib/test-cjs", + "module": "commonjs", + "moduleResolution": "node", + "ignoreDeprecations": "6.0", + "composite": true, + "incremental": true, + "strict": true, + "noImplicitAny": true, + "paths": { + "@wireio/sdk-outpost": ["./src"], + "@wireio/sdk-outpost/*": ["./src/*"] + } + }, + "references": [{ "path": "./tsconfig.cjs.json" }], + "include": ["tests"] +} diff --git a/packages/sdk-outpost/tsconfig.cjs.json b/packages/sdk-outpost/tsconfig.cjs.json new file mode 100644 index 0000000..b15a1fa --- /dev/null +++ b/packages/sdk-outpost/tsconfig.cjs.json @@ -0,0 +1,13 @@ +{ + "extends": "../../etc/tsconfig/tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/cjs", + "module": "commonjs", + "moduleResolution": "node", + "ignoreDeprecations": "6.0", + "strict": true, + "noImplicitAny": true + }, + "include": ["src"] +} diff --git a/packages/sdk-outpost/tsconfig.esm.json b/packages/sdk-outpost/tsconfig.esm.json new file mode 100644 index 0000000..35ab683 --- /dev/null +++ b/packages/sdk-outpost/tsconfig.esm.json @@ -0,0 +1,10 @@ +{ + "extends": "../../etc/tsconfig/tsconfig.base.esm.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/esm", + "strict": true, + "noImplicitAny": true + }, + "include": ["src"] +} diff --git a/packages/sdk-outpost/tsconfig.json b/packages/sdk-outpost/tsconfig.json new file mode 100644 index 0000000..75e4065 --- /dev/null +++ b/packages/sdk-outpost/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../etc/tsconfig/tsconfig.base.json", + "files": [], + "references": [ + { "path": "./tsconfig.esm.json" }, + { "path": "./tsconfig.cjs.json" }, + { "path": "./tsconfig.cjs.jest.json" } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9c96d40..43ed7c9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,15 +11,11 @@ overrides: '@aws-sdk/client-ssm': 3.1102.0 '@aws-sdk/client-sts': 3.1102.0 -pnpmfileChecksum: sha256-aSegbCvK5TyBUeMcgQLjFgZgcFmq1CMqHT+lQb93Qks= +pnpmfileChecksum: sha256-8SxVsi62NYbViQ0DPEJl+ttUXrF7HLpNiC5CJ8NzUT4= importers: .: - dependencies: - '@wireio/opp-typescript-models': - specifier: ^1.0.26 - version: 1.0.48 devDependencies: '@eslint/js': specifier: ^10.0.1 @@ -56,7 +52,7 @@ importers: version: 30.4.2(@types/node@25.5.0)(ts-node@10.9.2(@types/node@25.5.0)(typescript@6.0.2)) jest-environment-jsdom: specifier: ^30.3.0 - version: 30.4.1 + version: 30.4.1(bufferutil@4.1.0)(utf-8-validate@6.0.6) jest-junit: specifier: ^17.0.0 version: 17.0.0 @@ -102,7 +98,7 @@ importers: version: 6.0.1(webpack-dev-server@6.0.0)(webpack@5.104.1) webpack-dev-server: specifier: ^6.0.0 - version: 6.0.0(tslib@2.8.1)(webpack-cli@6.0.1)(webpack@5.104.1) + version: 6.0.0(bufferutil@4.1.0)(tslib@2.8.1)(utf-8-validate@6.0.6)(webpack-cli@6.0.1)(webpack@5.104.1) packages/sdk-core: dependencies: @@ -139,9 +135,6 @@ importers: '@noble/curves': specifier: 1.9.7 version: 1.9.7 - '@wireio/opp-typescript-models': - specifier: ^1.0.26 - version: 1.0.48 '@wireio/shared': specifier: workspace:* version: link:../shared @@ -192,6 +185,43 @@ importers: specifier: 6.0.2 version: 6.0.2 + packages/sdk-outpost: + dependencies: + '@coral-xyz/anchor': + specifier: ^0.32.1 + version: 0.32.1(bufferutil@4.1.0)(typescript@6.0.2)(utf-8-validate@6.0.6) + '@solana/spl-token': + specifier: ^0.3.11 + version: 0.3.11(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@6.0.2)(utf-8-validate@6.0.6))(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.2)(utf-8-validate@6.0.6) + '@solana/web3.js': + specifier: ^1.98.4 + version: 1.98.4(bufferutil@4.1.0)(typescript@6.0.2)(utf-8-validate@6.0.6) + '@wireio/outpost-ethereum-artifacts': + specifier: 0.3.0 + version: 0.3.0(ethers@6.17.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)) + '@wireio/outpost-solana-artifacts': + specifier: 0.3.0 + version: 0.3.0 + '@wireio/sdk-core': + specifier: workspace:* + version: link:../sdk-core + ethers: + specifier: ^6.15.0 + version: 6.17.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) + ts-pattern: + specifier: ^5.9.0 + version: 5.9.0 + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + rpc-websockets: + specifier: 9.3.8 + version: 9.3.8 + typescript: + specifier: 6.0.2 + version: 6.0.2 + packages/shared: dependencies: '@3fv/prelude-ts': @@ -339,6 +369,9 @@ packages: '@3fv/prelude-ts@0.8.42': resolution: {integrity: sha512-a+d8lRjkUlzY7cyqYbClWKmEj1gAYa6ELeuXnhouJikIii9WUpI5aKIGsk19n3edNd5uzvGrf4FsI7uqNqFM+g==} + '@adraffy/ens-normalize@1.11.1': + resolution: {integrity: sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==} + '@asamuzakjp/css-color@3.2.0': resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} @@ -563,6 +596,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@babel/template@7.29.7': resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} @@ -578,6 +615,20 @@ packages: '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + '@coral-xyz/anchor-errors@0.31.1': + resolution: {integrity: sha512-NhNEku4F3zzUSBtrYz84FzYWm48+9OvmT1Hhnwr6GnPQry2dsEqH/ti/7ASjjpoFTWRnPXrjAIT1qM6Isop+LQ==} + engines: {node: '>=10'} + + '@coral-xyz/anchor@0.32.1': + resolution: {integrity: sha512-zAyxFtfeje2FbMA1wzgcdVs7Hng/MijPKpRijoySPCicnvcTQs/+dnPZ/cR+LcXM9v9UYSyW81uRNYZtN5G4yg==} + engines: {node: '>=17'} + + '@coral-xyz/borsh@0.31.1': + resolution: {integrity: sha512-9N8AU9F0ubriKfNE3g1WF0/4dtlGXoBN/hd1PvbNBamBNwRgHxH4P+o3Zt7rSEloW1HUs6LfZEchlx9fW7POYw==} + engines: {node: '>=10'} + peerDependencies: + '@solana/web3.js': ^1.69.0 + '@cspotcode/source-map-support@0.8.1': resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} @@ -1003,10 +1054,17 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 + '@noble/curves@1.2.0': + resolution: {integrity: sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==} + '@noble/curves@1.9.7': resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==} engines: {node: ^14.21.3 || >=16} + '@noble/hashes@1.3.2': + resolution: {integrity: sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==} + engines: {node: '>= 16'} + '@noble/hashes@1.4.0': resolution: {integrity: sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==} engines: {node: '>= 16'} @@ -1060,9 +1118,6 @@ packages: resolution: {integrity: sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==} engines: {node: ^14.18.0 || >=16.0.0} - '@protobuf-ts/runtime@2.11.1': - resolution: {integrity: sha512-KuDaT1IfHkugM2pyz+FwiY80ejWrkH1pAtOBOZFuR6SXEFTsnb/jiQWQ1rCIrcKx2BtyxnxW6BWwsVSA/Ie+WQ==} - '@reduxjs/toolkit@2.12.0': resolution: {integrity: sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==} peerDependencies: @@ -1107,12 +1162,94 @@ packages: resolution: {integrity: sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==} engines: {node: '>=18.0.0'} + '@solana/buffer-layout-utils@0.2.0': + resolution: {integrity: sha512-szG4sxgJGktbuZYDg2FfNmkMi0DYQoVjN2h7ta1W1hPrwzarcFLBq9UpX1UjNXsNpT9dn+chgprtWGioUAr4/g==} + engines: {node: '>= 10'} + + '@solana/buffer-layout@4.0.1': + resolution: {integrity: sha512-E1ImOIAD1tBZFRdjeM4/pzTiTApC0AOBGwyAMS4fwIodCWArzJ3DWdoh8cKxeFM2fElkxBh2Aqts1BPC373rHA==} + engines: {node: '>=5.10'} + + '@solana/codecs-core@2.0.0-rc.1': + resolution: {integrity: sha512-bauxqMfSs8EHD0JKESaNmNuNvkvHSuN3bbWAF5RjOfDu2PugxHrvRebmYauvSumZ3cTfQ4HJJX6PG5rN852qyQ==} + peerDependencies: + typescript: '>=5' + + '@solana/codecs-core@2.3.0': + resolution: {integrity: sha512-oG+VZzN6YhBHIoSKgS5ESM9VIGzhWjEHEGNPSibiDTxFhsFWxNaz8LbMDPjBUE69r9wmdGLkrQ+wVPbnJcZPvw==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: '>=5.3.3' + + '@solana/codecs-data-structures@2.0.0-rc.1': + resolution: {integrity: sha512-rinCv0RrAVJ9rE/rmaibWJQxMwC5lSaORSZuwjopSUE6T0nb/MVg6Z1siNCXhh/HFTOg0l8bNvZHgBcN/yvXog==} + peerDependencies: + typescript: '>=5' + + '@solana/codecs-numbers@2.0.0-rc.1': + resolution: {integrity: sha512-J5i5mOkvukXn8E3Z7sGIPxsThRCgSdgTWJDQeZvucQ9PT6Y3HiVXJ0pcWiOWAoQ3RX8e/f4I3IC+wE6pZiJzDQ==} + peerDependencies: + typescript: '>=5' + + '@solana/codecs-numbers@2.3.0': + resolution: {integrity: sha512-jFvvwKJKffvG7Iz9dmN51OGB7JBcy2CJ6Xf3NqD/VP90xak66m/Lg48T01u5IQ/hc15mChVHiBm+HHuOFDUrQg==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: '>=5.3.3' + + '@solana/codecs-strings@2.0.0-rc.1': + resolution: {integrity: sha512-9/wPhw8TbGRTt6mHC4Zz1RqOnuPTqq1Nb4EyuvpZ39GW6O2t2Q7Q0XxiB3+BdoEjwA2XgPw6e2iRfvYgqty44g==} + peerDependencies: + fastestsmallesttextencoderdecoder: ^1.0.22 + typescript: '>=5' + + '@solana/codecs@2.0.0-rc.1': + resolution: {integrity: sha512-qxoR7VybNJixV51L0G1RD2boZTcxmwUWnKCaJJExQ5qNKwbpSyDdWfFJfM5JhGyKe9DnPVOZB+JHWXnpbZBqrQ==} + peerDependencies: + typescript: '>=5' + + '@solana/errors@2.0.0-rc.1': + resolution: {integrity: sha512-ejNvQ2oJ7+bcFAYWj225lyRkHnixuAeb7RQCixm+5mH4n1IA4Qya/9Bmfy5RAAHQzxK43clu3kZmL5eF9VGtYQ==} + hasBin: true + peerDependencies: + typescript: '>=5' + + '@solana/errors@2.3.0': + resolution: {integrity: sha512-66RI9MAbwYV0UtP7kGcTBVLxJgUxoZGm8Fbc0ah+lGiAw17Gugco6+9GrJCV83VyF2mDWyYnYM9qdI3yjgpnaQ==} + engines: {node: '>=20.18.0'} + hasBin: true + peerDependencies: + typescript: '>=5.3.3' + + '@solana/options@2.0.0-rc.1': + resolution: {integrity: sha512-mLUcR9mZ3qfHlmMnREdIFPf9dpMc/Bl66tLSOOWxw4ml5xMT2ohFn7WGqoKcu/UHkT9CrC6+amEdqCNvUqI7AA==} + peerDependencies: + typescript: '>=5' + + '@solana/spl-token-metadata@0.1.6': + resolution: {integrity: sha512-7sMt1rsm/zQOQcUWllQX9mD2O6KhSAtY1hFR2hfFwgqfFWzSY9E9GDvFVNYUI1F0iQKcm6HmePU9QbKRXTEBiA==} + engines: {node: '>=16'} + peerDependencies: + '@solana/web3.js': ^1.95.3 + + '@solana/spl-token@0.3.11': + resolution: {integrity: sha512-bvohO3rIMSVL24Pb+I4EYTJ6cL82eFpInEXD/I8K8upOGjpqHsKUoAempR/RnUlI1qSFNyFlWJfu6MNUgfbCQQ==} + engines: {node: '>=16'} + peerDependencies: + '@solana/web3.js': ^1.88.0 + + '@solana/web3.js@1.98.4': + resolution: {integrity: sha512-vv9lfnvjUsRiq//+j5pBdXig0IQdtzA0BRZ3bXEP4KaIyF1CcaydWqgyzQgfZMNIsWNWmG+AUHwPy4AHOD6gpw==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} '@standard-schema/utils@0.3.0': resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + '@swc/helpers@0.5.23': + resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} + '@tsconfig/node10@1.0.12': resolution: {integrity: sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==} @@ -1227,9 +1364,15 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node@12.20.55': + resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} + '@types/node@22.20.0': resolution: {integrity: sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==} + '@types/node@22.7.5': + resolution: {integrity: sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==} + '@types/node@25.5.0': resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==} @@ -1265,6 +1408,12 @@ packages: '@types/use-sync-external-store@0.0.6': resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + '@types/uuid@10.0.0': + resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} + + '@types/ws@7.4.7': + resolution: {integrity: sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww==} + '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} @@ -1526,8 +1675,13 @@ packages: webpack-dev-server: optional: true - '@wireio/opp-typescript-models@1.0.48': - resolution: {integrity: sha512-3HDC88AohYBBMuVdSqdI5tIRHPZWWTOaS+yQ/Xs1sCvhKuqlpF3KnYLcgIiXX3x6IC87dfo55xBfHPFcWpthmw==} + '@wireio/outpost-ethereum-artifacts@0.3.0': + resolution: {integrity: sha512-z//qpsX/OLOBBl/TRP854WbhZy79P/PuP2gfME7tN1kwChIoytrniXyVJ3tMvuMb9rvtjb5JuYfejG0lp5cEWA==} + peerDependencies: + ethers: ^6.15.0 + + '@wireio/outpost-solana-artifacts@0.3.0': + resolution: {integrity: sha512-zAiH9cI5pp2gSKFTsQp9qqjvlAGL49XK+oZjjOeQ41SW9gwXXWatMc10GuvmwpolfBeRyrIqiTfP7s0W9fNjjw==} '@xtuc/ieee754@1.2.0': resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} @@ -1563,10 +1717,17 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + aes-js@4.0.0-beta.5: + resolution: {integrity: sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==} + agent-base@7.1.4: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} + agentkeepalive@4.6.0: + resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} + engines: {node: '>= 8.0.0'} + ajv-formats@2.1.1: resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} peerDependencies: @@ -1661,6 +1822,12 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + base-x@3.0.11: + resolution: {integrity: sha512-xz7wQ8xDhdyP7tQxwdteLYeFfS68tSMNCZ/Y37WJ4bhGfKPpqEIlmIyueQHqOyoPhE6xNUqjzRr8ra0eF9VRvA==} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + baseline-browser-mapping@2.10.42: resolution: {integrity: sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==} engines: {node: '>=6.0.0'} @@ -1669,6 +1836,16 @@ packages: batch@0.6.1: resolution: {integrity: sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==} + bigint-buffer@1.1.5: + resolution: {integrity: sha512-trfYco6AoZ+rKhKnxA0hgX0HAbVP/s808/EuDSe2JDzUnCp/xAsli35Orvk67UrTEcwuxZqYZDmfA2RXJgxVvA==} + engines: {node: '>= 10.0.0'} + + bignumber.js@9.3.1: + resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + + bindings@1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + bluebird@3.7.2: resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} @@ -1688,6 +1865,9 @@ packages: boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + borsh@0.7.0: + resolution: {integrity: sha512-CLCsZGIBCFnPtkNnieW/a8wmreDmfUtjU2m9yHrzPXIlNbqVs0AQrSatSG6vdNYUqdc83tkQi2eHfF98ubzQLA==} + bowser@2.14.1: resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} @@ -1717,12 +1897,26 @@ packages: resolution: {integrity: sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==} engines: {node: '>= 6'} + bs58@4.0.1: + resolution: {integrity: sha512-Ok3Wdf5vOIlBrgCvTq96gBkJw+JUEzdBgyaza5HLtPm7yTHkjRy8+JzNyHF7BHa0bNWOQIp3m5YF0nnFcOIKLw==} + bser@2.1.1: resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + buffer-layout@1.2.2: + resolution: {integrity: sha512-kWSuLN694+KTk8SrYvCqwP2WcgQjoRCiF5b4QDvkkz8EmgD+aWAIceGFKMIAdmF/pH+vpgNV3d3kAKorcdAmWA==} + engines: {node: '>=4.5'} + + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + + bufferutil@4.1.0: + resolution: {integrity: sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==} + engines: {node: '>=6.14.2'} + bundle-name@4.1.0: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} engines: {node: '>=18'} @@ -1765,6 +1959,10 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + char-regex@1.0.2: resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} engines: {node: '>=10'} @@ -1821,6 +2019,10 @@ packages: resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} engines: {node: '>=18'} + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -1880,6 +2082,9 @@ packages: create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + cross-fetch@3.2.0: + resolution: {integrity: sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -1978,6 +2183,10 @@ packages: resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} engines: {node: '>=12'} + delay@5.0.0: + resolution: {integrity: sha512-ReEBKkIfe4ya47wlPYf/gu5ib6yUG0/Aez0JQZQz94kiWtRQvZIQbTiehsnwHvLSWJnQdhVeqYue7Id1dKr0qw==} + engines: {node: '>=10'} + depd@1.1.2: resolution: {integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==} engines: {node: '>= 0.6'} @@ -2081,6 +2290,12 @@ packages: resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} + es6-promise@4.2.8: + resolution: {integrity: sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==} + + es6-promisify@5.0.0: + resolution: {integrity: sha512-C+d6UdsYDk0lMebHNR4S2NybQMMngAOnOwYBQjTOiv0MkoJMP0Myw2mgpDLBcpfCmRLxyFqYhS/CfOENq4SJhQ==} + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -2161,6 +2376,13 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} + ethers@6.17.0: + resolution: {integrity: sha512-BpyrpIPJ3ydEVow8zGaz1DuPS7YU8DcWxuBnY9a0UA/lvAPwrMr+EPXsfrul628SRaekPNeIM4UFh/91GWZang==} + engines: {node: '>=14.0.0'} + + eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} @@ -2184,6 +2406,10 @@ packages: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} + eyes@0.1.8: + resolution: {integrity: sha512-GipyPsXO1anza0AOZdy69Im7hGFCNB7Y/NGjDlZGJ3GJJLtwNSb2vrzYrTYJRrRloVx7pl+bhUaTB8yiccPvFQ==} + engines: {node: '> 0.1.90'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -2193,6 +2419,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-stable-stringify@1.0.0: + resolution: {integrity: sha512-wpYMUmFu5f00Sm0cj2pfivpmawLZ0NKdviQ4w9zJeR8JVtOpOxHmLaJuj0vxvGqMJQWyP/COUkF75/57OKyRag==} + fast-uri@3.1.3: resolution: {integrity: sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==} @@ -2200,6 +2429,9 @@ packages: resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==} engines: {node: '>= 4.9.1'} + fastestsmallesttextencoderdecoder@1.0.22: + resolution: {integrity: sha512-Pb8d48e+oIuY4MaM64Cd7OW1gt4nxCHs7/ddPPZ/Ic3sg8yVGM7O9wDvZ7us6ScaUupzM+pfBolwtYhN1IxBIw==} + fb-watchman@2.0.2: resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} @@ -2216,6 +2448,9 @@ packages: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} + file-uri-to-path@1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -2403,6 +2638,9 @@ packages: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} + humanize-ms@1.2.1: + resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} + husky@9.1.7: resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} engines: {node: '>=18'} @@ -2426,6 +2664,9 @@ packages: peerDependencies: postcss: ^8.1.0 + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -2539,6 +2780,11 @@ packages: resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==} engines: {node: '>=0.10.0'} + isomorphic-ws@4.0.1: + resolution: {integrity: sha512-BhBvN2MBpWTaSHdWRb/bwdZJ1WaehQ2L1KngkCkfLUGF0mAWAT1sQUQacEmQ0jXkFw/czDXPNQSL5u2/Krsz1w==} + peerDependencies: + ws: '*' + istanbul-lib-coverage@3.2.2: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} engines: {node: '>=8'} @@ -2562,6 +2808,11 @@ packages: jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jayson@4.3.0: + resolution: {integrity: sha512-AauzHcUcqs8OBnCHOkJY280VaTiCm57AbuO7lqzcw7JapGj50BisE3xhksye4zlTSR1+1tAz67wLTl8tEH1obQ==} + engines: {node: '>=8'} + hasBin: true + jest-changed-files@30.4.1: resolution: {integrity: sha512-IuctmYrxi21iOSOaIXpJWalHyPAsVv0GeBHKDn8C1CA4W5htHn7INL+wdnL4Bo0+olEndvAFkmb++tIQJG+vvg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -2749,6 +3000,9 @@ packages: json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + json-stringify-safe@5.0.1: + resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} + json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} @@ -2938,6 +3192,19 @@ packages: no-case@3.0.4: resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + + node-gyp-build@4.8.4: + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} + hasBin: true + node-int64@0.4.0: resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} @@ -3244,6 +3511,9 @@ packages: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} + rpc-websockets@9.3.8: + resolution: {integrity: sha512-7r+fm4tSJmLf9GvZfL1DJ1SJwpagpp6AazqM0FUaeV7CA+7+NYINSk1syWa4tU/6OF2CyBicLtzENGmXRJH6wQ==} + rrweb-cssom@0.8.0: resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} @@ -3383,6 +3653,12 @@ packages: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} + stream-chain@2.2.5: + resolution: {integrity: sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA==} + + stream-json@1.9.1: + resolution: {integrity: sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw==} + string-length@4.0.2: resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==} engines: {node: '>=10'} @@ -3421,6 +3697,13 @@ packages: peerDependencies: webpack: ^5.27.0 + superstruct@0.15.5: + resolution: {integrity: sha512-4AOeU+P5UuE/4nOUkmcQdW5y7i9ndt1cQd/3iUe+LTz3RxESf/W/5lg4B74HbDMMv8PHnPnGCQFH45kBcrQYoQ==} + + superstruct@2.0.2: + resolution: {integrity: sha512-uV+TFRZdXsqXTL2pRvujROjdZQ4RAlBUS5BTh9IGm+jTqQntYThciG/qu57Gs69yjnVUSqdxF9YLmSnpupBW9A==} + engines: {node: '>=14.0.0'} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -3496,6 +3779,9 @@ packages: resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} engines: {node: '>=8'} + text-encoding-utf-8@1.0.2: + resolution: {integrity: sha512-8bw4MY9WjdsD2aMtO0OzOCY3pXGYNx2d2FfHRVUKkiCPDWjKuOlhLVASS+pD7VkLTVjW268LYJHwsnPFlBpbAg==} + thingies@2.6.0: resolution: {integrity: sha512-rMHRjmlFLM1R96UYPvpmnc3LYtdFrT33JIB7L9hetGue1qAPfn1N2LJeEjxUSidu1Iku+haLZXDuEXUHNGO/lg==} engines: {node: '>=10.18'} @@ -3531,10 +3817,16 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + toml@3.0.0: + resolution: {integrity: sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==} + tough-cookie@5.1.2: resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} engines: {node: '>=16'} + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + tr46@5.1.1: resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} engines: {node: '>=18'} @@ -3617,6 +3909,9 @@ packages: tslib@1.14.1: resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + tslib@2.7.0: + resolution: {integrity: sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -3664,6 +3959,9 @@ packages: engines: {node: '>=0.8.0'} hasBin: true + undici-types@6.19.8: + resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -3691,16 +3989,29 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + utf-8-validate@6.0.6: + resolution: {integrity: sha512-q3l3P9UtEEiAHcsgsqTgf9PPjctrDWoIXW3NpOHFdRDbLvu4DLIcxHangJ4RLrWkBcKjmcs/6NkerI8T/rE4LA==} + engines: {node: '>=6.14.2'} + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} utila@0.4.0: resolution: {integrity: sha512-Z0DbgELS9/L/75wZbro8xAnT50pBVFQZ+hUEueGDU5FN51YSCYM+jdxsfCiHjwNP/4LCDD0i/graKpeBnOXKRA==} + uuid@11.1.1: + resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==} + hasBin: true + uuid@14.0.1: resolution: {integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==} hasBin: true + uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + v8-compile-cache-lib@3.0.1: resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} @@ -3723,6 +4034,9 @@ packages: resolution: {integrity: sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==} engines: {node: '>=10.13.0'} + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + webidl-conversions@7.0.0: resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} engines: {node: '>=12'} @@ -3794,6 +4108,9 @@ packages: resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} engines: {node: '>=18'} + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -3824,6 +4141,18 @@ packages: resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + ws@7.5.13: + resolution: {integrity: sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==} + engines: {node: '>=8.3.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + ws@8.21.0: resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} engines: {node: '>=10.0.0'} @@ -3877,6 +4206,9 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + snapshots: '@3fv/guard@1.4.39': @@ -3889,6 +4221,8 @@ snapshots: hamt_plus: 1.0.2 list: 2.0.19 + '@adraffy/ens-normalize@1.11.1': {} + '@asamuzakjp/css-color@3.2.0': dependencies: '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) @@ -4218,6 +4552,8 @@ snapshots: '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 + '@babel/runtime@7.29.7': {} + '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 @@ -4243,6 +4579,35 @@ snapshots: '@bcoe/v8-coverage@0.2.3': {} + '@coral-xyz/anchor-errors@0.31.1': {} + + '@coral-xyz/anchor@0.32.1(bufferutil@4.1.0)(typescript@6.0.2)(utf-8-validate@6.0.6)': + dependencies: + '@coral-xyz/anchor-errors': 0.31.1 + '@coral-xyz/borsh': 0.31.1(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@6.0.2)(utf-8-validate@6.0.6)) + '@noble/hashes': 1.8.0 + '@solana/web3.js': 1.98.4(bufferutil@4.1.0)(typescript@6.0.2)(utf-8-validate@6.0.6) + bn.js: 5.2.4 + bs58: 4.0.1 + buffer-layout: 1.2.2 + camelcase: 6.3.0 + cross-fetch: 3.2.0 + eventemitter3: 4.0.7 + pako: 2.2.0 + superstruct: 0.15.5 + toml: 3.0.0 + transitivePeerDependencies: + - bufferutil + - encoding + - typescript + - utf-8-validate + + '@coral-xyz/borsh@0.31.1(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@6.0.2)(utf-8-validate@6.0.6))': + dependencies: + '@solana/web3.js': 1.98.4(bufferutil@4.1.0)(typescript@6.0.2)(utf-8-validate@6.0.6) + bn.js: 5.2.4 + buffer-layout: 1.2.2 + '@cspotcode/source-map-support@0.8.1': dependencies: '@jridgewell/trace-mapping': 0.3.9 @@ -4507,7 +4872,7 @@ snapshots: '@jest/console@30.4.1': dependencies: '@jest/types': 30.4.1 - '@types/node': 25.5.0 + '@types/node': 22.20.0 chalk: 4.1.2 jest-message-util: 30.4.1 jest-util: 30.4.1 @@ -4521,7 +4886,7 @@ snapshots: '@jest/test-result': 30.4.1 '@jest/transform': 30.4.1 '@jest/types': 30.4.1 - '@types/node': 25.5.0 + '@types/node': 22.20.0 ansi-escapes: 4.3.2 chalk: 4.1.2 ci-info: 4.4.0 @@ -4529,7 +4894,7 @@ snapshots: fast-json-stable-stringify: 2.1.0 graceful-fs: 4.2.11 jest-changed-files: 30.4.1 - jest-config: 30.4.2(@types/node@25.5.0)(ts-node@10.9.2(@types/node@25.5.0)(typescript@6.0.2)) + jest-config: 30.4.2(@types/node@22.20.0)(ts-node@10.9.2(@types/node@25.5.0)(typescript@6.0.2)) jest-haste-map: 30.4.1 jest-message-util: 30.4.1 jest-regex-util: 30.4.0 @@ -4551,22 +4916,22 @@ snapshots: '@jest/diff-sequences@30.4.0': {} - '@jest/environment-jsdom-abstract@30.4.1(jsdom@26.1.0)': + '@jest/environment-jsdom-abstract@30.4.1(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))': dependencies: '@jest/environment': 30.4.1 '@jest/fake-timers': 30.4.1 '@jest/types': 30.4.1 '@types/jsdom': 21.1.7 - '@types/node': 25.5.0 + '@types/node': 22.20.0 jest-mock: 30.4.1 jest-util: 30.4.1 - jsdom: 26.1.0 + jsdom: 26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) '@jest/environment@30.4.1': dependencies: '@jest/fake-timers': 30.4.1 '@jest/types': 30.4.1 - '@types/node': 25.5.0 + '@types/node': 22.20.0 jest-mock: 30.4.1 '@jest/expect-utils@30.4.1': @@ -4584,7 +4949,7 @@ snapshots: dependencies: '@jest/types': 30.4.1 '@sinonjs/fake-timers': 15.4.0 - '@types/node': 25.5.0 + '@types/node': 22.20.0 jest-message-util: 30.4.1 jest-mock: 30.4.1 jest-util: 30.4.1 @@ -4602,7 +4967,7 @@ snapshots: '@jest/pattern@30.4.0': dependencies: - '@types/node': 25.5.0 + '@types/node': 22.20.0 jest-regex-util: 30.4.0 '@jest/reporters@30.4.1': @@ -4613,7 +4978,7 @@ snapshots: '@jest/transform': 30.4.1 '@jest/types': 30.4.1 '@jridgewell/trace-mapping': 0.3.31 - '@types/node': 25.5.0 + '@types/node': 22.20.0 chalk: 4.1.2 collect-v8-coverage: 1.0.3 exit-x: 0.2.2 @@ -4689,7 +5054,7 @@ snapshots: '@jest/schemas': 30.4.1 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 25.5.0 + '@types/node': 22.20.0 '@types/yargs': 17.0.35 chalk: 4.1.2 @@ -4858,10 +5223,16 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true + '@noble/curves@1.2.0': + dependencies: + '@noble/hashes': 1.3.2 + '@noble/curves@1.9.7': dependencies: '@noble/hashes': 1.8.0 + '@noble/hashes@1.3.2': {} + '@noble/hashes@1.4.0': {} '@noble/hashes@1.8.0': {} @@ -4965,8 +5336,6 @@ snapshots: '@pkgr/core@0.3.6': {} - '@protobuf-ts/runtime@2.11.1': {} - '@reduxjs/toolkit@2.12.0(react-redux@9.3.0(@types/react@19.2.17)(react@19.2.7)(redux@5.0.1))(react@19.2.7)': dependencies: '@standard-schema/spec': 1.1.0 @@ -5022,10 +5391,146 @@ snapshots: dependencies: tslib: 2.8.1 + '@solana/buffer-layout-utils@0.2.0(bufferutil@4.1.0)(typescript@6.0.2)(utf-8-validate@6.0.6)': + dependencies: + '@solana/buffer-layout': 4.0.1 + '@solana/web3.js': 1.98.4(bufferutil@4.1.0)(typescript@6.0.2)(utf-8-validate@6.0.6) + bigint-buffer: 1.1.5 + bignumber.js: 9.3.1 + transitivePeerDependencies: + - bufferutil + - encoding + - typescript + - utf-8-validate + + '@solana/buffer-layout@4.0.1': + dependencies: + buffer: 6.0.3 + + '@solana/codecs-core@2.0.0-rc.1(typescript@6.0.2)': + dependencies: + '@solana/errors': 2.0.0-rc.1(typescript@6.0.2) + typescript: 6.0.2 + + '@solana/codecs-core@2.3.0(typescript@6.0.2)': + dependencies: + '@solana/errors': 2.3.0(typescript@6.0.2) + typescript: 6.0.2 + + '@solana/codecs-data-structures@2.0.0-rc.1(typescript@6.0.2)': + dependencies: + '@solana/codecs-core': 2.0.0-rc.1(typescript@6.0.2) + '@solana/codecs-numbers': 2.0.0-rc.1(typescript@6.0.2) + '@solana/errors': 2.0.0-rc.1(typescript@6.0.2) + typescript: 6.0.2 + + '@solana/codecs-numbers@2.0.0-rc.1(typescript@6.0.2)': + dependencies: + '@solana/codecs-core': 2.0.0-rc.1(typescript@6.0.2) + '@solana/errors': 2.0.0-rc.1(typescript@6.0.2) + typescript: 6.0.2 + + '@solana/codecs-numbers@2.3.0(typescript@6.0.2)': + dependencies: + '@solana/codecs-core': 2.3.0(typescript@6.0.2) + '@solana/errors': 2.3.0(typescript@6.0.2) + typescript: 6.0.2 + + '@solana/codecs-strings@2.0.0-rc.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.2)': + dependencies: + '@solana/codecs-core': 2.0.0-rc.1(typescript@6.0.2) + '@solana/codecs-numbers': 2.0.0-rc.1(typescript@6.0.2) + '@solana/errors': 2.0.0-rc.1(typescript@6.0.2) + fastestsmallesttextencoderdecoder: 1.0.22 + typescript: 6.0.2 + + '@solana/codecs@2.0.0-rc.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.2)': + dependencies: + '@solana/codecs-core': 2.0.0-rc.1(typescript@6.0.2) + '@solana/codecs-data-structures': 2.0.0-rc.1(typescript@6.0.2) + '@solana/codecs-numbers': 2.0.0-rc.1(typescript@6.0.2) + '@solana/codecs-strings': 2.0.0-rc.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.2) + '@solana/options': 2.0.0-rc.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.2) + typescript: 6.0.2 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + + '@solana/errors@2.0.0-rc.1(typescript@6.0.2)': + dependencies: + chalk: 5.6.2 + commander: 12.1.0 + typescript: 6.0.2 + + '@solana/errors@2.3.0(typescript@6.0.2)': + dependencies: + chalk: 5.6.2 + commander: 14.0.3 + typescript: 6.0.2 + + '@solana/options@2.0.0-rc.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.2)': + dependencies: + '@solana/codecs-core': 2.0.0-rc.1(typescript@6.0.2) + '@solana/codecs-data-structures': 2.0.0-rc.1(typescript@6.0.2) + '@solana/codecs-numbers': 2.0.0-rc.1(typescript@6.0.2) + '@solana/codecs-strings': 2.0.0-rc.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.2) + '@solana/errors': 2.0.0-rc.1(typescript@6.0.2) + typescript: 6.0.2 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + + '@solana/spl-token-metadata@0.1.6(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@6.0.2)(utf-8-validate@6.0.6))(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.2)': + dependencies: + '@solana/codecs': 2.0.0-rc.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.2) + '@solana/web3.js': 1.98.4(bufferutil@4.1.0)(typescript@6.0.2)(utf-8-validate@6.0.6) + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + - typescript + + '@solana/spl-token@0.3.11(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@6.0.2)(utf-8-validate@6.0.6))(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.2)(utf-8-validate@6.0.6)': + dependencies: + '@solana/buffer-layout': 4.0.1 + '@solana/buffer-layout-utils': 0.2.0(bufferutil@4.1.0)(typescript@6.0.2)(utf-8-validate@6.0.6) + '@solana/spl-token-metadata': 0.1.6(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@6.0.2)(utf-8-validate@6.0.6))(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.2) + '@solana/web3.js': 1.98.4(bufferutil@4.1.0)(typescript@6.0.2)(utf-8-validate@6.0.6) + buffer: 6.0.3 + transitivePeerDependencies: + - bufferutil + - encoding + - fastestsmallesttextencoderdecoder + - typescript + - utf-8-validate + + '@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@6.0.2)(utf-8-validate@6.0.6)': + dependencies: + '@babel/runtime': 7.29.7 + '@noble/curves': 1.9.7 + '@noble/hashes': 1.8.0 + '@solana/buffer-layout': 4.0.1 + '@solana/codecs-numbers': 2.3.0(typescript@6.0.2) + agentkeepalive: 4.6.0 + bn.js: 5.2.4 + borsh: 0.7.0 + bs58: 4.0.1 + buffer: 6.0.3 + fast-stable-stringify: 1.0.0 + jayson: 4.3.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) + node-fetch: 2.7.0 + rpc-websockets: 9.3.8 + superstruct: 2.0.2 + transitivePeerDependencies: + - bufferutil + - encoding + - typescript + - utf-8-validate + '@standard-schema/spec@1.1.0': {} '@standard-schema/utils@0.3.0': {} + '@swc/helpers@0.5.23': + dependencies: + tslib: 2.8.1 + '@tsconfig/node10@1.0.12': {} '@tsconfig/node12@1.0.11': {} @@ -5153,7 +5658,7 @@ snapshots: '@types/jsdom@21.1.7': dependencies: - '@types/node': 25.5.0 + '@types/node': 22.20.0 '@types/tough-cookie': 4.0.5 parse5: 7.3.0 @@ -5165,10 +5670,16 @@ snapshots: '@types/ms@2.1.0': {} + '@types/node@12.20.55': {} + '@types/node@22.20.0': dependencies: undici-types: 6.21.0 + '@types/node@22.7.5': + dependencies: + undici-types: 6.19.8 + '@types/node@25.5.0': dependencies: undici-types: 7.18.2 @@ -5204,6 +5715,12 @@ snapshots: '@types/use-sync-external-store@0.0.6': {} + '@types/uuid@10.0.0': {} + + '@types/ws@7.4.7': + dependencies: + '@types/node': 22.20.0 + '@types/ws@8.18.1': dependencies: '@types/node': 22.20.0 @@ -5468,16 +5985,18 @@ snapshots: webpack: 5.104.1(webpack-cli@6.0.1) webpack-cli: 6.0.1(webpack-dev-server@6.0.0)(webpack@5.104.1) optionalDependencies: - webpack-dev-server: 6.0.0(tslib@2.8.1)(webpack-cli@6.0.1)(webpack@5.104.1) + webpack-dev-server: 6.0.0(bufferutil@4.1.0)(tslib@2.8.1)(utf-8-validate@6.0.6)(webpack-cli@6.0.1)(webpack@5.104.1) '@webpack-cli/serve@3.0.1(webpack-cli@6.0.1)(webpack@5.104.1)': dependencies: webpack: 5.104.1(postcss@8.5.16)(webpack-cli@6.0.1) webpack-cli: 6.0.1(webpack@5.104.1) - '@wireio/opp-typescript-models@1.0.48': + '@wireio/outpost-ethereum-artifacts@0.3.0(ethers@6.17.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))': dependencies: - '@protobuf-ts/runtime': 2.11.1 + ethers: 6.17.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) + + '@wireio/outpost-solana-artifacts@0.3.0': {} '@xtuc/ieee754@1.2.0': {} @@ -5507,8 +6026,14 @@ snapshots: acorn@8.17.0: {} + aes-js@4.0.0-beta.5: {} + agent-base@7.1.4: {} + agentkeepalive@4.6.0: + dependencies: + humanize-ms: 1.2.1 + ajv-formats@2.1.1(ajv@8.20.0): optionalDependencies: ajv: 8.20.0 @@ -5623,10 +6148,26 @@ snapshots: balanced-match@4.0.4: {} + base-x@3.0.11: + dependencies: + safe-buffer: 5.2.1 + + base64-js@1.5.1: {} + baseline-browser-mapping@2.10.42: {} batch@0.6.1: {} + bigint-buffer@1.1.5: + dependencies: + bindings: 1.5.0 + + bignumber.js@9.3.1: {} + + bindings@1.5.0: + dependencies: + file-uri-to-path: 1.0.0 + bluebird@3.7.2: {} bn.js@4.12.4: {} @@ -5654,6 +6195,12 @@ snapshots: boolbase@1.0.0: {} + borsh@0.7.0: + dependencies: + bn.js: 5.2.4 + bs58: 4.0.1 + text-encoding-utf-8: 1.0.2 + bowser@2.14.1: {} brace-expansion@1.1.15: @@ -5687,12 +6234,28 @@ snapshots: dependencies: fast-json-stable-stringify: 2.1.0 + bs58@4.0.1: + dependencies: + base-x: 3.0.11 + bser@2.1.1: dependencies: node-int64: 0.4.0 buffer-from@1.1.2: {} + buffer-layout@1.2.2: {} + + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + bufferutil@4.1.0: + dependencies: + node-gyp-build: 4.8.4 + optional: true + bundle-name@4.1.0: dependencies: run-applescript: 7.1.0 @@ -5729,6 +6292,8 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + chalk@5.6.2: {} + char-regex@1.0.2: {} chokidar@5.0.0: @@ -5773,6 +6338,8 @@ snapshots: commander@12.1.0: {} + commander@14.0.3: {} + commander@2.20.3: {} commander@8.3.0: {} @@ -5829,6 +6396,12 @@ snapshots: create-require@1.1.1: {} + cross-fetch@3.2.0: + dependencies: + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -5903,6 +6476,8 @@ snapshots: define-lazy-prop@3.0.0: {} + delay@5.0.0: {} + depd@1.1.2: {} depd@2.0.0: {} @@ -5997,6 +6572,12 @@ snapshots: dependencies: es-errors: 1.3.0 + es6-promise@4.2.8: {} + + es6-promisify@5.0.0: + dependencies: + es6-promise: 4.2.8 + escalade@3.2.0: {} escape-html@1.0.3: {} @@ -6084,6 +6665,21 @@ snapshots: etag@1.8.1: {} + ethers@6.17.0(bufferutil@4.1.0)(utf-8-validate@6.0.6): + dependencies: + '@adraffy/ens-normalize': 1.11.1 + '@noble/curves': 1.2.0 + '@noble/hashes': 1.3.2 + '@types/node': 22.7.5 + aes-js: 4.0.0-beta.5 + tslib: 2.7.0 + ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + eventemitter3@4.0.7: {} + eventemitter3@5.0.4: {} events@3.3.0: {} @@ -6144,16 +6740,22 @@ snapshots: transitivePeerDependencies: - supports-color + eyes@0.1.8: {} + fast-deep-equal@3.1.3: {} fast-json-stable-stringify@2.1.0: {} fast-levenshtein@2.0.6: {} + fast-stable-stringify@1.0.0: {} + fast-uri@3.1.3: {} fastest-levenshtein@1.0.16: {} + fastestsmallesttextencoderdecoder@1.0.22: {} + fb-watchman@2.0.2: dependencies: bser: 2.1.1 @@ -6166,6 +6768,8 @@ snapshots: dependencies: flat-cache: 4.0.1 + file-uri-to-path@1.0.0: {} + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -6383,6 +6987,10 @@ snapshots: human-signals@2.1.0: {} + humanize-ms@1.2.1: + dependencies: + ms: 2.1.3 + husky@9.1.7: {} hyperdyperid@1.2.0: {} @@ -6399,6 +7007,8 @@ snapshots: dependencies: postcss: 8.5.16 + ieee754@1.2.1: {} + ignore@5.3.2: {} ignore@7.0.6: {} @@ -6473,6 +7083,10 @@ snapshots: isobject@3.0.1: {} + isomorphic-ws@4.0.1(ws@7.5.13(bufferutil@4.1.0)(utf-8-validate@6.0.6)): + dependencies: + ws: 7.5.13(bufferutil@4.1.0)(utf-8-validate@6.0.6) + istanbul-lib-coverage@3.2.2: {} istanbul-lib-instrument@6.0.3: @@ -6510,6 +7124,24 @@ snapshots: optionalDependencies: '@pkgjs/parseargs': 0.11.0 + jayson@4.3.0(bufferutil@4.1.0)(utf-8-validate@6.0.6): + dependencies: + '@types/connect': 3.4.38 + '@types/node': 12.20.55 + '@types/ws': 7.4.7 + commander: 2.20.3 + delay: 5.0.0 + es6-promisify: 5.0.0 + eyes: 0.1.8 + isomorphic-ws: 4.0.1(ws@7.5.13(bufferutil@4.1.0)(utf-8-validate@6.0.6)) + json-stringify-safe: 5.0.1 + stream-json: 1.9.1 + uuid: 8.3.2 + ws: 7.5.13(bufferutil@4.1.0)(utf-8-validate@6.0.6) + transitivePeerDependencies: + - bufferutil + - utf-8-validate + jest-changed-files@30.4.1: dependencies: execa: 5.1.1 @@ -6522,7 +7154,7 @@ snapshots: '@jest/expect': 30.4.1 '@jest/test-result': 30.4.1 '@jest/types': 30.4.1 - '@types/node': 25.5.0 + '@types/node': 22.20.0 chalk: 4.1.2 co: 4.6.0 dedent: 1.7.2 @@ -6561,6 +7193,38 @@ snapshots: - supports-color - ts-node + jest-config@30.4.2(@types/node@22.20.0)(ts-node@10.9.2(@types/node@25.5.0)(typescript@6.0.2)): + dependencies: + '@babel/core': 7.29.7 + '@jest/get-type': 30.1.0 + '@jest/pattern': 30.4.0 + '@jest/test-sequencer': 30.4.1 + '@jest/types': 30.4.1 + babel-jest: 30.4.1(@babel/core@7.29.7) + chalk: 4.1.2 + ci-info: 4.4.0 + deepmerge: 4.3.1 + glob: 10.5.0 + graceful-fs: 4.2.11 + jest-circus: 30.4.2 + jest-docblock: 30.4.0 + jest-environment-node: 30.4.1 + jest-regex-util: 30.4.0 + jest-resolve: 30.4.1 + jest-runner: 30.4.2 + jest-util: 30.4.1 + jest-validate: 30.4.1 + parse-json: 5.2.0 + pretty-format: 30.4.1 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + '@types/node': 22.20.0 + ts-node: 10.9.2(@types/node@25.5.0)(typescript@6.0.2) + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + jest-config@30.4.2(@types/node@25.5.0)(ts-node@10.9.2(@types/node@25.5.0)(typescript@6.0.2)): dependencies: '@babel/core': 7.29.7 @@ -6612,11 +7276,11 @@ snapshots: jest-util: 30.4.1 pretty-format: 30.4.1 - jest-environment-jsdom@30.4.1: + jest-environment-jsdom@30.4.1(bufferutil@4.1.0)(utf-8-validate@6.0.6): dependencies: '@jest/environment': 30.4.1 - '@jest/environment-jsdom-abstract': 30.4.1(jsdom@26.1.0) - jsdom: 26.1.0 + '@jest/environment-jsdom-abstract': 30.4.1(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)) + jsdom: 26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - bufferutil - supports-color @@ -6627,7 +7291,7 @@ snapshots: '@jest/environment': 30.4.1 '@jest/fake-timers': 30.4.1 '@jest/types': 30.4.1 - '@types/node': 25.5.0 + '@types/node': 22.20.0 jest-mock: 30.4.1 jest-util: 30.4.1 jest-validate: 30.4.1 @@ -6635,7 +7299,7 @@ snapshots: jest-haste-map@30.4.1: dependencies: '@jest/types': 30.4.1 - '@types/node': 25.5.0 + '@types/node': 22.20.0 anymatch: 3.1.3 fb-watchman: 2.0.2 graceful-fs: 4.2.11 @@ -6682,7 +7346,7 @@ snapshots: jest-mock@30.4.1: dependencies: '@jest/types': 30.4.1 - '@types/node': 25.5.0 + '@types/node': 22.20.0 jest-util: 30.4.1 jest-pnp-resolver@1.2.3(jest-resolve@30.4.1): @@ -6716,7 +7380,7 @@ snapshots: '@jest/test-result': 30.4.1 '@jest/transform': 30.4.1 '@jest/types': 30.4.1 - '@types/node': 25.5.0 + '@types/node': 22.20.0 chalk: 4.1.2 emittery: 0.13.1 exit-x: 0.2.2 @@ -6745,7 +7409,7 @@ snapshots: '@jest/test-result': 30.4.1 '@jest/transform': 30.4.1 '@jest/types': 30.4.1 - '@types/node': 25.5.0 + '@types/node': 22.20.0 chalk: 4.1.2 cjs-module-lexer: 2.2.0 collect-v8-coverage: 1.0.3 @@ -6792,7 +7456,7 @@ snapshots: jest-util@30.4.1: dependencies: '@jest/types': 30.4.1 - '@types/node': 25.5.0 + '@types/node': 22.20.0 chalk: 4.1.2 ci-info: 4.4.0 graceful-fs: 4.2.11 @@ -6811,7 +7475,7 @@ snapshots: dependencies: '@jest/test-result': 30.4.1 '@jest/types': 30.4.1 - '@types/node': 25.5.0 + '@types/node': 22.20.0 ansi-escapes: 4.3.2 chalk: 4.1.2 emittery: 0.13.1 @@ -6826,7 +7490,7 @@ snapshots: jest-worker@30.4.1: dependencies: - '@types/node': 25.5.0 + '@types/node': 22.20.0 '@ungap/structured-clone': 1.3.2 jest-util: 30.4.1 merge-stream: 2.0.0 @@ -6856,7 +7520,7 @@ snapshots: argparse: 1.0.10 esprima: 4.0.1 - jsdom@26.1.0: + jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6): dependencies: cssstyle: 4.6.0 data-urls: 5.0.0 @@ -6876,7 +7540,7 @@ snapshots: whatwg-encoding: 3.1.1 whatwg-mimetype: 4.0.0 whatwg-url: 14.2.0 - ws: 8.21.0 + ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) xml-name-validator: 5.0.0 transitivePeerDependencies: - bufferutil @@ -6895,6 +7559,8 @@ snapshots: json-stable-stringify-without-jsonify@1.0.1: {} + json-stringify-safe@5.0.1: {} + json5@2.2.3: {} keyv@4.5.4: @@ -7055,6 +7721,13 @@ snapshots: lower-case: 2.0.2 tslib: 2.8.1 + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + + node-gyp-build@4.8.4: + optional: true + node-int64@0.4.0: {} node-releases@2.0.50: {} @@ -7344,6 +8017,19 @@ snapshots: transitivePeerDependencies: - supports-color + rpc-websockets@9.3.8: + dependencies: + '@swc/helpers': 0.5.23 + '@types/uuid': 10.0.0 + '@types/ws': 8.18.1 + buffer: 6.0.3 + eventemitter3: 5.0.4 + uuid: 11.1.1 + ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) + optionalDependencies: + bufferutil: 4.1.0 + utf-8-validate: 6.0.6 + rrweb-cssom@0.8.0: {} run-applescript@7.1.0: {} @@ -7493,6 +8179,12 @@ snapshots: statuses@2.0.2: {} + stream-chain@2.2.5: {} + + stream-json@1.9.1: + dependencies: + stream-chain: 2.2.5 + string-length@4.0.2: dependencies: char-regex: 1.0.2 @@ -7528,6 +8220,10 @@ snapshots: dependencies: webpack: 5.104.1(postcss@8.5.16)(webpack-cli@6.0.1) + superstruct@0.15.5: {} + + superstruct@2.0.2: {} + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -7577,6 +8273,8 @@ snapshots: glob: 7.2.3 minimatch: 3.1.5 + text-encoding-utf-8@1.0.2: {} + thingies@2.6.0(tslib@2.8.1): dependencies: tslib: 2.8.1 @@ -7604,10 +8302,14 @@ snapshots: toidentifier@1.0.1: {} + toml@3.0.0: {} + tough-cookie@5.1.2: dependencies: tldts: 6.1.86 + tr46@0.0.3: {} + tr46@5.1.1: dependencies: punycode: 2.3.1 @@ -7679,6 +8381,8 @@ snapshots: tslib@1.14.1: {} + tslib@2.7.0: {} + tslib@2.8.1: {} tsyringe@4.10.0: @@ -7719,6 +8423,8 @@ snapshots: uglify-js@3.19.3: optional: true + undici-types@6.19.8: {} + undici-types@6.21.0: {} undici-types@7.18.2: {} @@ -7766,12 +8472,21 @@ snapshots: dependencies: react: 19.2.7 + utf-8-validate@6.0.6: + dependencies: + node-gyp-build: 4.8.4 + optional: true + util-deprecate@1.0.2: {} utila@0.4.0: {} + uuid@11.1.1: {} + uuid@14.0.1: {} + uuid@8.3.2: {} + v8-compile-cache-lib@3.0.1: {} v8-to-istanbul@9.3.0: @@ -7794,6 +8509,8 @@ snapshots: dependencies: graceful-fs: 4.2.11 + webidl-conversions@3.0.1: {} + webidl-conversions@7.0.0: {} webpack-cli@6.0.1(webpack-dev-server@6.0.0)(webpack@5.104.1): @@ -7813,7 +8530,7 @@ snapshots: webpack: 5.104.1(webpack-cli@6.0.1) webpack-merge: 6.0.1 optionalDependencies: - webpack-dev-server: 6.0.0(tslib@2.8.1)(webpack-cli@6.0.1)(webpack@5.104.1) + webpack-dev-server: 6.0.0(bufferutil@4.1.0)(tslib@2.8.1)(utf-8-validate@6.0.6)(webpack-cli@6.0.1)(webpack@5.104.1) webpack-cli@6.0.1(webpack@5.104.1): dependencies: @@ -7844,7 +8561,7 @@ snapshots: transitivePeerDependencies: - tslib - webpack-dev-server@6.0.0(tslib@2.8.1)(webpack-cli@6.0.1)(webpack@5.104.1): + webpack-dev-server@6.0.0(bufferutil@4.1.0)(tslib@2.8.1)(utf-8-validate@6.0.6)(webpack-cli@6.0.1)(webpack@5.104.1): dependencies: '@types/bonjour': 3.5.13 '@types/connect-history-api-fallback': 1.5.4 @@ -7870,7 +8587,7 @@ snapshots: serve-index: 1.9.2 tinyglobby: 0.2.17 webpack-dev-middleware: 8.0.3(tslib@2.8.1)(webpack@5.104.1) - ws: 8.21.0 + ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) optionalDependencies: webpack: 5.104.1(webpack-cli@6.0.1) webpack-cli: 6.0.1(webpack-dev-server@6.0.0)(webpack@5.104.1) @@ -7985,6 +8702,11 @@ snapshots: tr46: 5.1.1 webidl-conversions: 7.0.0 + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + which@2.0.2: dependencies: isexe: 2.0.0 @@ -8014,7 +8736,15 @@ snapshots: imurmurhash: 0.1.4 signal-exit: 4.1.0 - ws@8.21.0: {} + ws@7.5.13(bufferutil@4.1.0)(utf-8-validate@6.0.6): + optionalDependencies: + bufferutil: 4.1.0 + utf-8-validate: 6.0.6 + + ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6): + optionalDependencies: + bufferutil: 4.1.0 + utf-8-validate: 6.0.6 wsl-utils@0.3.1: dependencies: @@ -8056,3 +8786,5 @@ snapshots: yn@3.1.1: {} yocto-queue@0.1.0: {} + + zod@4.4.3: {} diff --git a/tsconfig.json b/tsconfig.json index 4e7e765..670cf71 100755 --- a/tsconfig.json +++ b/tsconfig.json @@ -26,6 +26,9 @@ { "path": "./packages/sdk-core/tsconfig.json" }, + { + "path": "./packages/sdk-outpost/tsconfig.json" + }, { "path": "./packages/wallet-ext-sdk/tsconfig.json" }, @@ -36,4 +39,4 @@ "path": "./examples/web-logging-example/tsconfig.json" } ] -} \ No newline at end of file +}