From dc3a1c6fc122a4a3c6d16761fe6cab9f9de6de51 Mon Sep 17 00:00:00 2001 From: joshglogau Date: Tue, 22 Sep 2026 16:27:06 -0400 Subject: [PATCH 1/3] feat(sdk-outpost): add native operator collateral workflows --- CLAUDE.md | 1 + README.md | 7 ++ packages/sdk-outpost/README.md | 32 +++++ .../ethereum/EthereumCollateralClient.ts | 112 +++++++++++++++++ .../clients/ethereum/EthereumOutpostClient.ts | 5 + .../sdk-outpost/src/clients/ethereum/index.ts | 1 + .../clients/solana/SolanaCollateralClient.ts | 101 +++++++++++++++ .../src/clients/solana/SolanaOutpostClient.ts | 5 + .../sdk-outpost/src/clients/solana/index.ts | 1 + packages/sdk-outpost/src/collateral/Types.ts | 28 +++++ .../sdk-outpost/src/collateral/Validation.ts | 51 ++++++++ packages/sdk-outpost/src/collateral/index.ts | 2 + packages/sdk-outpost/src/index.ts | 1 + .../ethereum/EthereumCollateralClient.test.ts | 104 +++++++++++++++ .../solana/SolanaCollateralClient.test.ts | 119 ++++++++++++++++++ .../tests/collateral/Validation.test.ts | 45 +++++++ 16 files changed, 615 insertions(+) create mode 100644 packages/sdk-outpost/src/clients/ethereum/EthereumCollateralClient.ts create mode 100644 packages/sdk-outpost/src/clients/solana/SolanaCollateralClient.ts create mode 100644 packages/sdk-outpost/src/collateral/Types.ts create mode 100644 packages/sdk-outpost/src/collateral/Validation.ts create mode 100644 packages/sdk-outpost/src/collateral/index.ts create mode 100644 packages/sdk-outpost/tests/clients/ethereum/EthereumCollateralClient.test.ts create mode 100644 packages/sdk-outpost/tests/clients/solana/SolanaCollateralClient.test.ts create mode 100644 packages/sdk-outpost/tests/collateral/Validation.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 93195ec8..64d24011 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -244,6 +244,7 @@ All generated or modified code **must** include JSDoc comments (`/** ... */`), c - `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. +- Operator collateral uses raw custody units, not reserve normalization: bigint amounts, positive u64 token code and aggregate depot balance bounded by `2^62-1`. Keep native ETH deposit/withdraw and SOL deposit capabilities explicit; no public SOL operator withdrawal exists in this artifact suite. The `onSubmitted` callback records a source hash before confirmation; neither that callback nor a confirmed receipt proves depot acceptance. Never put operator ABIs/PDAs in Hub or substitute liquid-staking exits. - 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 diff --git a/README.md b/README.md index 2f0785ee..dc4cc16f 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,13 @@ A monorepo containing shared TypeScript libraries for Wire applications, providing cross-platform utilities for logging, type guards, async helpers, blockchain SDK primitives, and protobuf code-generation tooling. +The operator collateral addition in `sdk-outpost` exposes native Ethereum +deposit/withdraw-request and native Solana deposit through verified clients. +It validates exact custody units and depot capacity, and retains source receipts +before confirmation. See [the SDK collateral contract](packages/sdk-outpost/README.md#operator-collateral). +This source addition requires the normal release workflow before npm consumers +can use it; it does not add Solana withdrawal or change deployed contracts. + ## Packages | Package | Description | npm | diff --git a/packages/sdk-outpost/README.md b/packages/sdk-outpost/README.md index e5f60762..4d11ca96 100644 --- a/packages/sdk-outpost/README.md +++ b/packages/sdk-outpost/README.md @@ -14,6 +14,38 @@ the exact producer commits, runtime artifacts, and ethers v6/Anchor bindings used by the SDK. Package versions are managed and published only through the repository release workflow. +## Operator collateral + +The source API adds `ethereum.collateral` and `solana.collateral` to verified +outpost clients. Consumers must wait for a release containing this addition; +the earlier published `0.0.3` release does not contain it. + +| Client | Supported | Explicitly unavailable | +| --- | --- | --- | +| Ethereum | `depositNative`, `requestNativeWithdrawal`, `nativeTokenCode` | Generic ERC20 ingress / incomplete generic-token exit | +| Solana | `createNativeDepositInstruction`, `depositNative` | Public operator withdrawal request, SPL collateral ingress | + +Use generated `SystemContracts.SysioOpregOperatortype` roles, bigint token code +and amount, plus the latest **entire** depot bucket balance (including locked +and queued funds). Amounts are raw custody units: wei or lamports, never reserve +normalization. The shared validator enforces the depot's `2^62 - 1` aggregate +ceiling. Ethereum verifies that the requested code is the configured native +asset and that the supplied SEC1 public key belongs to the connected signer. +Solana uses generated IDL instructions and canonical custody accounts; the +program's native-route check runs during transaction preflight. + +`onSubmitted({ transactionId })` runs after broadcast but before confirmation, +so the caller can persist a non-secret receipt even when confirmation times out. +The returned identifier proves only a source submission. Independently observe +depot acceptance/rejection, queue request ID and eventual refund or payout. +An Ethereum withdrawal event's placeholder ID is not the depot queue ID. + +Callers still own Wire registration, AuthEx identity checks, live free-capacity +checks, one-pending-request policy, network selection and readiness gates. A +validated source receipt never proves those downstream states. Previously +credited rewards use Wire `claimpay` / `claimuwfee`, not these custody clients. +Private keys and mutable endpoint catalogs remain caller-owned. + ## Install ```sh diff --git a/packages/sdk-outpost/src/clients/ethereum/EthereumCollateralClient.ts b/packages/sdk-outpost/src/clients/ethereum/EthereumCollateralClient.ts new file mode 100644 index 00000000..2e8a3f00 --- /dev/null +++ b/packages/sdk-outpost/src/clients/ethereum/EthereumCollateralClient.ts @@ -0,0 +1,112 @@ +import type { OperatorRegistry } from "@wireio/outpost-ethereum-artifacts" +import { + computeAddress, + getAddress, + SigningKey, + type Provider, + type Signer +} from "ethers" +import { + assertOperatorCollateralRequest, + type OperatorCollateralRequest, + type OperatorCollateralSubmission, + type OperatorCollateralSubmissionOptions +} from "../../collateral/index.js" +import { assertEthereumSigner } from "./Connection.js" + +const Confirmations = 1 + +/** Native operator collateral on a verified Ethereum outpost; no arbitrary ERC20 ingress. */ +export class EthereumCollateralClient { + /** Bind to the generated registry and the verified caller-owned connection. */ + constructor( + private readonly registry: OperatorRegistry, + private readonly connection: Provider | Signer + ) {} + + /** Producer suite capability, independent of role admission and live funding. */ + readonly capabilities = Object.freeze({ + nativeDeposit: true, + nativeWithdrawal: true + }) + + /** Read the registry's configured native code instead of guessing a token identity. */ + async nativeTokenCode(): Promise { + return this.registry.nativeTokenCode() + } + + /** Escrow raw native units; the caller must separately observe depot credit or refund. */ + async depositNative( + request: OperatorCollateralRequest, + options: OperatorCollateralSubmissionOptions = {} + ): Promise { + assertOperatorCollateralRequest(request) + const publicKey = await this.assertIdentity(request) + await this.registry.deposit.staticCall( + request.operatorType, + publicKey, + request.tokenCode, + request.amount, + { value: request.amount } + ) + const transaction = await this.registry.deposit( + request.operatorType, + publicKey, + request.tokenCode, + request.amount, + { value: request.amount } + ), + submission = { transactionId: transaction.hash } + options.onSubmitted?.(submission) + await transaction.wait(Confirmations) + return submission + } + + /** Enqueue a native withdrawal; this is not an immediate payout or a depot request id. */ + async requestNativeWithdrawal( + request: OperatorCollateralRequest, + options: OperatorCollateralSubmissionOptions = {} + ): Promise { + assertOperatorCollateralRequest(request, false) + const publicKey = await this.assertIdentity(request) + await this.registry.withdraw.staticCall( + publicKey, + request.tokenCode, + request.amount + ) + const transaction = await this.registry.withdraw( + publicKey, + request.tokenCode, + request.amount + ), + submission = { transactionId: transaction.hash } + options.onSubmitted?.(submission) + await transaction.wait(Confirmations) + return submission + } + + /** Match both the native token and the SEC1 public key to the live signer. */ + private async assertIdentity( + request: OperatorCollateralRequest + ): Promise { + const signer = assertEthereumSigner(this.connection, "Operator collateral"), + nativeCode = await this.nativeTokenCode() + if (nativeCode === 0n || request.tokenCode !== nativeCode) { + throw new Error( + "Only the configured native collateral asset is supported." + ) + } + if (!request.publicKey) + throw new Error("The depositor public key is required.") + const publicKey = SigningKey.computePublicKey(request.publicKey, true) + if ( + getAddress(computeAddress(publicKey)) !== + getAddress(await signer.getAddress()) + ) { + throw new Error( + "The collateral public key does not match the connected wallet." + ) + } + return publicKey + } +} diff --git a/packages/sdk-outpost/src/clients/ethereum/EthereumOutpostClient.ts b/packages/sdk-outpost/src/clients/ethereum/EthereumOutpostClient.ts index 418f4219..70c3a012 100644 --- a/packages/sdk-outpost/src/clients/ethereum/EthereumOutpostClient.ts +++ b/packages/sdk-outpost/src/clients/ethereum/EthereumOutpostClient.ts @@ -9,6 +9,7 @@ import { EthereumContractName, OutpostChainFamily } from "../../deployments/index.js" +import { EthereumCollateralClient } from "./EthereumCollateralClient.js" import { OutpostDeploymentVerifier } from "../../verification/index.js" import { ethereumProvider } from "./Connection.js" import { EthereumContractMap, EthereumOutpostClientOptions } from "./Types.js" @@ -43,6 +44,7 @@ export class EthereumOutpostClient { readonly provider: Provider, private readonly artifactSuite: OutpostArtifactSuite ) { + this.collateral = new EthereumCollateralClient(this.contract(EthereumContractName.OperatorRegistry), options.connection) this.reserves = new EthereumReserveClient( this.contract(EthereumContractName.ReserveManager), options.connection @@ -59,6 +61,9 @@ export class EthereumOutpostClient { } } + /** Native operator collateral for the selected verified artifact suite. */ + readonly collateral: EthereumCollateralClient + /** Reserve creation, cancellation, and reads for this verified outpost. */ readonly reserves: EthereumReserveClient diff --git a/packages/sdk-outpost/src/clients/ethereum/index.ts b/packages/sdk-outpost/src/clients/ethereum/index.ts index 7f8f194b..aa9e0148 100644 --- a/packages/sdk-outpost/src/clients/ethereum/index.ts +++ b/packages/sdk-outpost/src/clients/ethereum/index.ts @@ -2,3 +2,4 @@ export * from "./EthereumReserveSwapClient.js" export * from "./EthereumReserveClient.js" export * from "./EthereumNodeOwnerClient.js" export * from "./Types.js" +export * from "./EthereumCollateralClient.js" diff --git a/packages/sdk-outpost/src/clients/solana/SolanaCollateralClient.ts b/packages/sdk-outpost/src/clients/solana/SolanaCollateralClient.ts new file mode 100644 index 00000000..a1f221ed --- /dev/null +++ b/packages/sdk-outpost/src/clients/solana/SolanaCollateralClient.ts @@ -0,0 +1,101 @@ +import { BN, type AnchorProvider, type Program } from "@coral-xyz/anchor" +import { + PublicKey, + SystemProgram, + Transaction, + type TransactionInstruction +} from "@solana/web3.js" +import type { LiqsolCore } from "@wireio/outpost-solana-artifacts" +import { + assertOperatorCollateralRequest, + type OperatorCollateralRequest, + type OperatorCollateralSubmission, + type OperatorCollateralSubmissionOptions +} from "../../collateral/index.js" + +const Seeds = { + config: "outpost_config", + registry: "operator_registry", + outbound: "outbound_message_buffer", + vault: "outpost_vault", + position: "collateral_position" + } as const, + TokenCodeBytes = 8, + Commitment = "confirmed" + +/** Native operator collateral, deliberately separate from liquid-staking withdrawals. */ +export class SolanaCollateralClient { + /** Bind to the program created from the verified producer artifact suite. */ + constructor( + private readonly provider: AnchorProvider, + private readonly program: Program + ) {} + + /** No public operator collateral withdrawal instruction exists in this suite. */ + readonly capabilities = Object.freeze({ + nativeDeposit: true, + nativeWithdrawal: false + }) + + /** Build the producer-defined native deposit instruction without exposing PDA work to consumers. */ + async createNativeDepositInstruction( + request: OperatorCollateralRequest + ): Promise { + assertOperatorCollateralRequest(request) + const depositor = this.provider.wallet.publicKey + if (!depositor) + throw new Error("Operator collateral requires a connected Solana wallet.") + const tokenCode = new BN(request.tokenCode.toString()), + derive = (seeds: Buffer[]) => + PublicKey.findProgramAddressSync(seeds, this.program.programId)[0] + return this.program.methods + .deposit( + request.operatorType, + tokenCode, + new BN(request.amount.toString()) + ) + .accounts({ + depositor, + config: derive([Buffer.from(Seeds.config)]), + operatorRegistry: derive([Buffer.from(Seeds.registry)]), + outboundMessageBuffer: derive([Buffer.from(Seeds.outbound)]), + vault: derive([Buffer.from(Seeds.vault)]), + collateralPosition: derive([ + Buffer.from(Seeds.position), + depositor.toBuffer(), + tokenCode.toArrayLike(Buffer, "le", TokenCodeBytes) + ]), + systemProgram: SystemProgram.programId + }) + .instruction() + } + + /** Submit native custody and retain its signature before confirmation. Depot acceptance is separate. */ + async depositNative( + request: OperatorCollateralRequest, + options: OperatorCollateralSubmissionOptions = {} + ): Promise { + const instruction = await this.createNativeDepositInstruction(request), + latest = await this.provider.connection.getLatestBlockhash(Commitment), + transaction = new Transaction({ + ...latest, + feePayer: this.provider.wallet.publicKey + }).add(instruction), + signed = await this.provider.wallet.signTransaction(transaction), + transactionId = await this.provider.connection.sendRawTransaction( + signed.serialize(), + { preflightCommitment: Commitment } + ), + submission = { transactionId } + options.onSubmitted?.(submission) + const confirmation = await this.provider.connection.confirmTransaction( + { ...latest, signature: transactionId }, + Commitment + ) + if (confirmation.value.err) + throw new Error( + `Solana collateral submission failed: ${JSON.stringify(confirmation.value.err)}` + ) + return submission + } +} diff --git a/packages/sdk-outpost/src/clients/solana/SolanaOutpostClient.ts b/packages/sdk-outpost/src/clients/solana/SolanaOutpostClient.ts index 01f05665..2f98d91b 100644 --- a/packages/sdk-outpost/src/clients/solana/SolanaOutpostClient.ts +++ b/packages/sdk-outpost/src/clients/solana/SolanaOutpostClient.ts @@ -10,6 +10,7 @@ import { OutpostChainFamily, SolanaProgramName } from "../../deployments/index.js" +import { SolanaCollateralClient } from "./SolanaCollateralClient.js" import { OutpostDeploymentVerifier } from "../../verification/index.js" import { SolanaOutpostClientOptions, SolanaProgramMap } from "./Types.js" import { SolanaReserveClient } from "./SolanaReserveClient.js" @@ -50,10 +51,14 @@ export class SolanaOutpostClient { }, options.provider ) + this.collateral = new SolanaCollateralClient(options.provider, this.liqsolCore) this.reserves = new SolanaReserveClient(options.provider, this.liqsolCore) this.swaps = new SolanaReserveSwapClient(options.provider, this.liqsolCore) } + /** Native operator collateral for the selected verified artifact suite. */ + readonly collateral: SolanaCollateralClient + /** Reserve creation, cancellation, and reads for this verified outpost. */ readonly reserves: SolanaReserveClient diff --git a/packages/sdk-outpost/src/clients/solana/index.ts b/packages/sdk-outpost/src/clients/solana/index.ts index 3bd9d25d..c305d034 100644 --- a/packages/sdk-outpost/src/clients/solana/index.ts +++ b/packages/sdk-outpost/src/clients/solana/index.ts @@ -2,3 +2,4 @@ export * from "./SolanaReserveSwapClient.js" export * from "./SolanaReserveAddresses.js" export * from "./SolanaReserveClient.js" export * from "./Types.js" +export * from "./SolanaCollateralClient.js" diff --git a/packages/sdk-outpost/src/collateral/Types.ts b/packages/sdk-outpost/src/collateral/Types.ts new file mode 100644 index 00000000..822a1d39 --- /dev/null +++ b/packages/sdk-outpost/src/collateral/Types.ts @@ -0,0 +1,28 @@ +import type { SystemContracts } from "@wireio/sdk-core" + +/** Native custody units, never reserve-normalized amounts. */ +export interface OperatorCollateralRequest { + operatorType: SystemContracts.SysioOpregOperatortype + tokenCode: bigint + amount: bigint + /** Latest depot bucket balance, including locked and queued collateral. */ + depotBalance: bigint + /** Compressed or uncompressed SEC1 key for Ethereum; unused on Solana. */ + publicKey?: string +} + +/** A source submission is not proof that the depot accepted the collateral. */ +export interface OperatorCollateralSubmission { + transactionId: string +} + +/** Retain the source identifier before confirmation can time out. */ +export interface OperatorCollateralSubmissionOptions { + onSubmitted?: (submission: OperatorCollateralSubmission) => void +} + +/** Operations supported by the selected producer artifact suite. */ +export interface OperatorCollateralCapabilities { + nativeDeposit: boolean + nativeWithdrawal: boolean +} diff --git a/packages/sdk-outpost/src/collateral/Validation.ts b/packages/sdk-outpost/src/collateral/Validation.ts new file mode 100644 index 00000000..d7d08549 --- /dev/null +++ b/packages/sdk-outpost/src/collateral/Validation.ts @@ -0,0 +1,51 @@ +import { SystemContracts } from "@wireio/sdk-core" +import type { OperatorCollateralRequest } from "./Types.js" + +/** Depot asset magnitude bounds every collateral bucket, including raw wei. */ +export const OperatorCollateralMaximum = (1n << 62n) - 1n +const Unsigned64Maximum = (1n << 64n) - 1n, + OperatorRoles = [ + SystemContracts.SysioOpregOperatortype.OPERATOR_TYPE_PRODUCER, + SystemContracts.SysioOpregOperatortype.OPERATOR_TYPE_BATCH, + SystemContracts.SysioOpregOperatortype.OPERATOR_TYPE_UNDERWRITER + ] + +/** Reject unsupported roles, unsafe integers and unavailable depot headroom. */ +export function assertOperatorCollateralRequest( + request: OperatorCollateralRequest, + deposit = true +): void { + if (!OperatorRoles.includes(request.operatorType)) { + throw new Error("Choose a producer, batch operator or underwriter role.") + } + if ( + typeof request.tokenCode !== "bigint" || + request.tokenCode <= 0n || + request.tokenCode > Unsigned64Maximum + ) { + throw new Error("Collateral token code must be a positive u64 bigint.") + } + if ( + typeof request.amount !== "bigint" || + request.amount <= 0n || + request.amount > OperatorCollateralMaximum + ) { + throw new Error("Collateral amount exceeds the positive depot asset range.") + } + if ( + typeof request.depotBalance !== "bigint" || + request.depotBalance < 0n || + request.depotBalance > OperatorCollateralMaximum + ) { + throw new Error("A valid current depot collateral balance is required.") + } + if ( + deposit && + request.amount > OperatorCollateralMaximum - request.depotBalance + ) { + throw new Error("Deposit exceeds the remaining depot collateral capacity.") + } + if (!deposit && request.amount > request.depotBalance) { + throw new Error("Withdrawal exceeds the depot collateral balance.") + } +} diff --git a/packages/sdk-outpost/src/collateral/index.ts b/packages/sdk-outpost/src/collateral/index.ts new file mode 100644 index 00000000..421334f2 --- /dev/null +++ b/packages/sdk-outpost/src/collateral/index.ts @@ -0,0 +1,2 @@ +export * from "./Types.js" +export * from "./Validation.js" diff --git a/packages/sdk-outpost/src/index.ts b/packages/sdk-outpost/src/index.ts index 974408ce..2fdad8a9 100644 --- a/packages/sdk-outpost/src/index.ts +++ b/packages/sdk-outpost/src/index.ts @@ -3,3 +3,4 @@ export * from "./artifacts/index.js" export * from "./deployments/index.js" export * from "./reserves/index.js" export * from "./verification/index.js" +export * from "./collateral/index.js" diff --git a/packages/sdk-outpost/tests/clients/ethereum/EthereumCollateralClient.test.ts b/packages/sdk-outpost/tests/clients/ethereum/EthereumCollateralClient.test.ts new file mode 100644 index 00000000..dc500289 --- /dev/null +++ b/packages/sdk-outpost/tests/clients/ethereum/EthereumCollateralClient.test.ts @@ -0,0 +1,104 @@ +import type { OperatorRegistry } from "@wireio/outpost-ethereum-artifacts" +import { SystemContracts } from "@wireio/sdk-core" +import { Wallet } from "ethers" +import { + EthereumCollateralClient, + OperatorCollateralMaximum +} from "@wireio/sdk-outpost" + +/** Observe custody writes without making network requests. */ +function fixture() { + const signer = Wallet.createRandom(), + wait = jest.fn(async () => ({ status: 1 })), + method = () => + Object.assign( + jest.fn(async () => ({ hash: "0x1234", wait })), + { staticCall: jest.fn(async (): Promise => undefined) } + ), + registry = { + nativeTokenCode: jest.fn(async () => 1n), + deposit: method(), + withdraw: method() + }, + client = new EthereumCollateralClient( + registry as unknown as OperatorRegistry, + signer + ), + request = { + operatorType: + SystemContracts.SysioOpregOperatortype.OPERATOR_TYPE_PRODUCER, + tokenCode: 1n, + amount: 1_000_000_000_000_000_001n, + depotBalance: 0n, + publicKey: signer.signingKey.publicKey + } + return { signer, registry, client, request, wait } +} + +describe("EthereumCollateralClient", () => { + it("preserves raw wei, compresses the signer's key and records submission before confirmation", async () => { + const { client, registry, request, wait, signer } = fixture(), + onSubmitted = jest.fn(() => expect(wait).not.toHaveBeenCalled()) + await expect( + client.depositNative(request, { onSubmitted }) + ).resolves.toEqual({ transactionId: "0x1234" }) + expect(registry.deposit).toHaveBeenCalledWith( + request.operatorType, + signer.signingKey.compressedPublicKey, + 1n, + request.amount, + { value: request.amount } + ) + expect(registry.deposit.staticCall).toHaveBeenCalledTimes(1) + expect(wait).toHaveBeenCalledWith(1) + expect(onSubmitted).toHaveBeenCalledWith({ transactionId: "0x1234" }) + }) + + it("rejects token/key mismatches and aggregate overflow before custody moves", async () => { + const { client, registry, request } = fixture() + await expect( + client.depositNative({ ...request, tokenCode: 2n }) + ).rejects.toThrow("native collateral") + await expect( + client.depositNative({ + ...request, + publicKey: Wallet.createRandom().signingKey.publicKey + }) + ).rejects.toThrow("does not match") + await expect( + client.depositNative({ + ...request, + depotBalance: OperatorCollateralMaximum + }) + ).rejects.toThrow("remaining depot") + expect(registry.deposit).not.toHaveBeenCalled() + }) + + it("sends a withdrawal request without presenting the source hash as a queue id", async () => { + const { client, registry, request, signer } = fixture() + await expect( + client.requestNativeWithdrawal({ + ...request, + depotBalance: request.amount + }) + ).resolves.toEqual({ transactionId: "0x1234" }) + expect(registry.withdraw).toHaveBeenCalledWith( + signer.signingKey.compressedPublicKey, + 1n, + request.amount + ) + await expect(client.requestNativeWithdrawal(request)).rejects.toThrow( + "exceeds the depot" + ) + }) + + it("retains the submitted hash even when confirmation fails", async () => { + const { client, request, wait } = fixture(), + onSubmitted = jest.fn() + wait.mockRejectedValueOnce(new Error("RPC lost") as never) + await expect( + client.depositNative(request, { onSubmitted }) + ).rejects.toThrow("RPC lost") + expect(onSubmitted).toHaveBeenCalledWith({ transactionId: "0x1234" }) + }) +}) diff --git a/packages/sdk-outpost/tests/clients/solana/SolanaCollateralClient.test.ts b/packages/sdk-outpost/tests/clients/solana/SolanaCollateralClient.test.ts new file mode 100644 index 00000000..83a73d96 --- /dev/null +++ b/packages/sdk-outpost/tests/clients/solana/SolanaCollateralClient.test.ts @@ -0,0 +1,119 @@ +import { Program, BorshInstructionCoder, type BN } from "@coral-xyz/anchor" +import { + liqsolCoreIdl, + type LiqsolCore +} from "@wireio/outpost-solana-artifacts" +import { SystemContracts } from "@wireio/sdk-core" +import { SolanaCollateralClient } from "@wireio/sdk-outpost" +import { PublicKey, SystemProgram } from "@solana/web3.js" +import { + createOutpostDeploymentProfileFixture, + createSolanaProviderFixture +} from "../../Fixtures.js" + +interface DecodedCollateralArguments { + amount: BN +} + +describe("SolanaCollateralClient", () => { + it("records the source signature before confirmation and reports a failed instruction", async () => { + const profile = createOutpostDeploymentProfileFixture(), + provider = createSolanaProviderFixture(profile), + program = new Program( + { + ...liqsolCoreIdl, + address: profile.solana.programs.liqsolCore.address + }, + provider + ), + client = new SolanaCollateralClient(provider, program), + signature = "4".repeat(64), + confirm = jest + .spyOn(provider.connection, "confirmTransaction") + .mockResolvedValue({ + context: { slot: 1 }, + value: { err: { InstructionError: [0, "InvalidArgument"] } } + }), + onSubmitted = jest.fn(() => expect(confirm).not.toHaveBeenCalled()) + jest + .spyOn(provider.connection, "getLatestBlockhash") + .mockResolvedValue({ + blockhash: "1".repeat(32), + lastValidBlockHeight: 99 + }) + jest + .spyOn(provider.connection, "sendRawTransaction") + .mockResolvedValue(signature) + await expect( + client.depositNative( + { + operatorType: + SystemContracts.SysioOpregOperatortype.OPERATOR_TYPE_BATCH, + tokenCode: 1n, + amount: 1n, + depotBalance: 0n + }, + { onSubmitted } + ) + ).rejects.toThrow("InvalidArgument") + expect(onSubmitted).toHaveBeenCalledWith({ transactionId: signature }) + expect(confirm).toHaveBeenCalledWith( + { blockhash: "1".repeat(32), lastValidBlockHeight: 99, signature }, + "confirmed" + ) + }) + + it("encodes exact lamports with the producer-defined custody accounts and no withdrawal capability", async () => { + const profile = createOutpostDeploymentProfileFixture(), + provider = createSolanaProviderFixture(profile), + program = new Program( + { + ...liqsolCoreIdl, + address: profile.solana.programs.liqsolCore.address + }, + provider + ), + client = new SolanaCollateralClient(provider, program), + amount = 9_007_199_254_740_993n, + instruction = await client.createNativeDepositInstruction({ + operatorType: + SystemContracts.SysioOpregOperatortype.OPERATOR_TYPE_UNDERWRITER, + tokenCode: 1n, + amount, + depotBalance: 0n + }), + position = PublicKey.findProgramAddressSync( + [ + Buffer.from("collateral_position"), + provider.wallet.publicKey.toBuffer(), + Buffer.from([1, 0, 0, 0, 0, 0, 0, 0]) + ], + program.programId + )[0], + decoded = new BorshInstructionCoder(liqsolCoreIdl).decode( + instruction.data + )! + expect(decoded.name).toBe("deposit") + expect((decoded.data as DecodedCollateralArguments).amount.toString()).toBe( + amount.toString() + ) + expect( + instruction.keys.some( + key => key.pubkey.equals(position) && key.isWritable + ) + ).toBe(true) + expect( + instruction.keys.some( + key => key.pubkey.equals(provider.wallet.publicKey) && key.isSigner + ) + ).toBe(true) + expect( + instruction.keys.some(key => key.pubkey.equals(SystemProgram.programId)) + ).toBe(true) + expect(client.capabilities).toEqual({ + nativeDeposit: true, + nativeWithdrawal: false + }) + expect("requestNativeWithdrawal" in client).toBe(false) + }) +}) diff --git a/packages/sdk-outpost/tests/collateral/Validation.test.ts b/packages/sdk-outpost/tests/collateral/Validation.test.ts new file mode 100644 index 00000000..b2a963a5 --- /dev/null +++ b/packages/sdk-outpost/tests/collateral/Validation.test.ts @@ -0,0 +1,45 @@ +import { SystemContracts } from "@wireio/sdk-core" +import { + assertOperatorCollateralRequest, + OperatorCollateralMaximum +} from "@wireio/sdk-outpost" + +const request = { + operatorType: SystemContracts.SysioOpregOperatortype.OPERATOR_TYPE_BATCH, + tokenCode: 1n, + amount: 1n, + depotBalance: 0n +} + +describe("operator collateral bounds", () => { + it("accepts the final raw unit of depot capacity and rejects the next", () => { + expect(() => + assertOperatorCollateralRequest({ + ...request, + depotBalance: OperatorCollateralMaximum - 1n + }) + ).not.toThrow() + expect(() => + assertOperatorCollateralRequest({ + ...request, + depotBalance: OperatorCollateralMaximum + }) + ).toThrow("remaining") + }) + it.each([0n, -1n, OperatorCollateralMaximum + 1n, 1 as unknown as bigint])( + "rejects unsafe amounts %s", + amount => { + expect(() => + assertOperatorCollateralRequest({ ...request, amount }) + ).toThrow("amount") + } + ) + it("rejects unknown roles and invalid token ids", () => { + expect(() => + assertOperatorCollateralRequest({ ...request, operatorType: 0 }) + ).toThrow("role") + expect(() => + assertOperatorCollateralRequest({ ...request, tokenCode: 1n << 64n }) + ).toThrow("u64") + }) +}) From d8c38592944198cdee9927a208f71e72b0cc4d11 Mon Sep 17 00:00:00 2001 From: joshglogau Date: Thu, 24 Sep 2026 14:27:15 -0400 Subject: [PATCH 2/3] fix(sdk-outpost): bound collateral confirmation and reuse Solana helpers --- CLAUDE.md | 1 + README.md | 3 +- packages/sdk-outpost/README.md | 7 +- .../ethereum/EthereumCollateralClient.ts | 3 +- .../clients/ethereum/EthereumOutpostClient.ts | 5 +- .../solana/SolanaCollateralAddresses.ts | 32 +++ .../clients/solana/SolanaCollateralClient.ts | 98 ++++--- .../clients/solana/SolanaOutpostAddresses.ts | 27 ++ .../src/clients/solana/SolanaOutpostClient.ts | 5 +- .../clients/solana/SolanaReserveAddresses.ts | 27 +- .../clients/solana/SolanaReserveSwapClient.ts | 60 ++-- packages/sdk-outpost/src/collateral/Types.ts | 7 + .../src/util/SolanaConfirmation.ts | 37 +++ .../tests/clients/OutpostClient.test.ts | 8 + .../ethereum/EthereumCollateralClient.test.ts | 47 ++- .../solana/SolanaCollateralClient.test.ts | 269 ++++++++++++++---- .../solana/SolanaOutpostClient.test.ts | 2 +- 17 files changed, 472 insertions(+), 166 deletions(-) create mode 100644 packages/sdk-outpost/src/clients/solana/SolanaCollateralAddresses.ts create mode 100644 packages/sdk-outpost/src/clients/solana/SolanaOutpostAddresses.ts create mode 100644 packages/sdk-outpost/src/util/SolanaConfirmation.ts diff --git a/CLAUDE.md b/CLAUDE.md index 64d24011..3a19766f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -245,6 +245,7 @@ All generated or modified code **must** include JSDoc comments (`/** ... */`), c - 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. - Operator collateral uses raw custody units, not reserve normalization: bigint amounts, positive u64 token code and aggregate depot balance bounded by `2^62-1`. Keep native ETH deposit/withdraw and SOL deposit capabilities explicit; no public SOL operator withdrawal exists in this artifact suite. The `onSubmitted` callback records a source hash before confirmation; neither that callback nor a confirmed receipt proves depot acceptance. Never put operator ABIs/PDAs in Hub or substitute liquid-staking exits. +- Solana collateral confirmation uses bounded HTTP signature-status polling; preserve the source receipt on RPC failure, expiry, or timeout and never retry the custody write automatically. Reuse shared outpost addresses and signature-status checks across reserve and collateral clients; keep custody workflows separate. - 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 diff --git a/README.md b/README.md index dc4cc16f..be1c60d0 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,8 @@ A monorepo containing shared TypeScript libraries for Wire applications, providi The operator collateral addition in `sdk-outpost` exposes native Ethereum deposit/withdraw-request and native Solana deposit through verified clients. It validates exact custody units and depot capacity, and retains source receipts -before confirmation. See [the SDK collateral contract](packages/sdk-outpost/README.md#operator-collateral). +before confirmation. Solana collateral confirms through bounded HTTP polling, +including RPC gateways without WebSockets. See [the SDK collateral contract](packages/sdk-outpost/README.md#operator-collateral). This source addition requires the normal release workflow before npm consumers can use it; it does not add Solana withdrawal or change deployed contracts. diff --git a/packages/sdk-outpost/README.md b/packages/sdk-outpost/README.md index 4d11ca96..d0a36ad1 100644 --- a/packages/sdk-outpost/README.md +++ b/packages/sdk-outpost/README.md @@ -18,7 +18,7 @@ repository release workflow. The source API adds `ethereum.collateral` and `solana.collateral` to verified outpost clients. Consumers must wait for a release containing this addition; -the earlier published `0.0.3` release does not contain it. +a version bump alone does not publish the API. | Client | Supported | Explicitly unavailable | | --- | --- | --- | @@ -40,6 +40,11 @@ The returned identifier proves only a source submission. Independently observe depot acceptance/rejection, queue request ID and eventual refund or payout. An Ethereum withdrawal event's placeholder ID is not the depot queue ID. +Solana collateral confirmation polls HTTP signature status for up to two minutes; +it does not require a WebSocket endpoint. RPC failures, blockhash expiry and +timeouts retain the submitted signature through `onSubmitted`. Inspect that +signature and depot state before retrying a deposit. + Callers still own Wire registration, AuthEx identity checks, live free-capacity checks, one-pending-request policy, network selection and readiness gates. A validated source receipt never proves those downstream states. Previously diff --git a/packages/sdk-outpost/src/clients/ethereum/EthereumCollateralClient.ts b/packages/sdk-outpost/src/clients/ethereum/EthereumCollateralClient.ts index 2e8a3f00..2b7e9cbd 100644 --- a/packages/sdk-outpost/src/clients/ethereum/EthereumCollateralClient.ts +++ b/packages/sdk-outpost/src/clients/ethereum/EthereumCollateralClient.ts @@ -8,6 +8,7 @@ import { } from "ethers" import { assertOperatorCollateralRequest, + type OperatorCollateralCapabilities, type OperatorCollateralRequest, type OperatorCollateralSubmission, type OperatorCollateralSubmissionOptions @@ -28,7 +29,7 @@ export class EthereumCollateralClient { readonly capabilities = Object.freeze({ nativeDeposit: true, nativeWithdrawal: true - }) + } satisfies OperatorCollateralCapabilities) /** Read the registry's configured native code instead of guessing a token identity. */ async nativeTokenCode(): Promise { diff --git a/packages/sdk-outpost/src/clients/ethereum/EthereumOutpostClient.ts b/packages/sdk-outpost/src/clients/ethereum/EthereumOutpostClient.ts index 70c3a012..871f9aec 100644 --- a/packages/sdk-outpost/src/clients/ethereum/EthereumOutpostClient.ts +++ b/packages/sdk-outpost/src/clients/ethereum/EthereumOutpostClient.ts @@ -44,7 +44,10 @@ export class EthereumOutpostClient { readonly provider: Provider, private readonly artifactSuite: OutpostArtifactSuite ) { - this.collateral = new EthereumCollateralClient(this.contract(EthereumContractName.OperatorRegistry), options.connection) + this.collateral = new EthereumCollateralClient( + this.contract(EthereumContractName.OperatorRegistry), + options.connection + ) this.reserves = new EthereumReserveClient( this.contract(EthereumContractName.ReserveManager), options.connection diff --git a/packages/sdk-outpost/src/clients/solana/SolanaCollateralAddresses.ts b/packages/sdk-outpost/src/clients/solana/SolanaCollateralAddresses.ts new file mode 100644 index 00000000..67fdc1a6 --- /dev/null +++ b/packages/sdk-outpost/src/clients/solana/SolanaCollateralAddresses.ts @@ -0,0 +1,32 @@ +import { BN } from "@coral-xyz/anchor" +import type { PublicKey } from "@solana/web3.js" +import { SolanaOutpostAddresses } from "./SolanaOutpostAddresses.js" + +const SolanaCollateralSeed = { + registry: Buffer.from("operator_registry"), + vault: Buffer.from("outpost_vault"), + position: Buffer.from("collateral_position") + } as const, + TokenCodeBytes = 8 + +/** Producer-defined collateral PDAs absent from the generated IDL's seed metadata. */ +export class SolanaCollateralAddresses extends SolanaOutpostAddresses { + /** Derive the singleton operator registry. */ + operatorRegistry(): PublicKey { + return this.derive([SolanaCollateralSeed.registry]) + } + + /** Derive the native collateral custody vault. */ + vault(): PublicKey { + return this.derive([SolanaCollateralSeed.vault]) + } + + /** Derive a position after the request's token code has passed u64 validation. */ + position(depositor: PublicKey, tokenCode: bigint): PublicKey { + return this.derive([ + SolanaCollateralSeed.position, + depositor.toBuffer(), + new BN(tokenCode.toString()).toArrayLike(Buffer, "le", TokenCodeBytes) + ]) + } +} diff --git a/packages/sdk-outpost/src/clients/solana/SolanaCollateralClient.ts b/packages/sdk-outpost/src/clients/solana/SolanaCollateralClient.ts index a1f221ed..38c17c9a 100644 --- a/packages/sdk-outpost/src/clients/solana/SolanaCollateralClient.ts +++ b/packages/sdk-outpost/src/clients/solana/SolanaCollateralClient.ts @@ -1,6 +1,5 @@ import { BN, type AnchorProvider, type Program } from "@coral-xyz/anchor" import { - PublicKey, SystemProgram, Transaction, type TransactionInstruction @@ -8,34 +7,38 @@ import { import type { LiqsolCore } from "@wireio/outpost-solana-artifacts" import { assertOperatorCollateralRequest, + type OperatorCollateralCapabilities, type OperatorCollateralRequest, type OperatorCollateralSubmission, type OperatorCollateralSubmissionOptions } from "../../collateral/index.js" -const Seeds = { - config: "outpost_config", - registry: "operator_registry", - outbound: "outbound_message_buffer", - vault: "outpost_vault", - position: "collateral_position" - } as const, - TokenCodeBytes = 8, - Commitment = "confirmed" +import { + isSolanaTransactionConfirmed, + SolanaConfirmationCommitment +} from "../../util/SolanaConfirmation.js" +import { SolanaCollateralAddresses } from "./SolanaCollateralAddresses.js" + +const ConfirmationPollIntervalMs = 1_000, + ConfirmationTimeoutMs = 120_000 /** Native operator collateral, deliberately separate from liquid-staking withdrawals. */ export class SolanaCollateralClient { + private readonly addresses: SolanaCollateralAddresses + /** Bind to the program created from the verified producer artifact suite. */ constructor( private readonly provider: AnchorProvider, private readonly program: Program - ) {} + ) { + this.addresses = new SolanaCollateralAddresses(program.programId) + } /** No public operator collateral withdrawal instruction exists in this suite. */ readonly capabilities = Object.freeze({ nativeDeposit: true, nativeWithdrawal: false - }) + } satisfies OperatorCollateralCapabilities) /** Build the producer-defined native deposit instruction without exposing PDA work to consumers. */ async createNativeDepositInstruction( @@ -45,9 +48,7 @@ export class SolanaCollateralClient { const depositor = this.provider.wallet.publicKey if (!depositor) throw new Error("Operator collateral requires a connected Solana wallet.") - const tokenCode = new BN(request.tokenCode.toString()), - derive = (seeds: Buffer[]) => - PublicKey.findProgramAddressSync(seeds, this.program.programId)[0] + const tokenCode = new BN(request.tokenCode.toString()) return this.program.methods .deposit( request.operatorType, @@ -56,15 +57,14 @@ export class SolanaCollateralClient { ) .accounts({ depositor, - config: derive([Buffer.from(Seeds.config)]), - operatorRegistry: derive([Buffer.from(Seeds.registry)]), - outboundMessageBuffer: derive([Buffer.from(Seeds.outbound)]), - vault: derive([Buffer.from(Seeds.vault)]), - collateralPosition: derive([ - Buffer.from(Seeds.position), - depositor.toBuffer(), - tokenCode.toArrayLike(Buffer, "le", TokenCodeBytes) - ]), + config: this.addresses.outpostConfig(), + operatorRegistry: this.addresses.operatorRegistry(), + outboundMessageBuffer: this.addresses.outboundMessageBuffer(), + vault: this.addresses.vault(), + collateralPosition: this.addresses.position( + depositor, + request.tokenCode + ), systemProgram: SystemProgram.programId }) .instruction() @@ -76,7 +76,9 @@ export class SolanaCollateralClient { options: OperatorCollateralSubmissionOptions = {} ): Promise { const instruction = await this.createNativeDepositInstruction(request), - latest = await this.provider.connection.getLatestBlockhash(Commitment), + latest = await this.provider.connection.getLatestBlockhash( + SolanaConfirmationCommitment + ), transaction = new Transaction({ ...latest, feePayer: this.provider.wallet.publicKey @@ -84,18 +86,46 @@ export class SolanaCollateralClient { signed = await this.provider.wallet.signTransaction(transaction), transactionId = await this.provider.connection.sendRawTransaction( signed.serialize(), - { preflightCommitment: Commitment } + { preflightCommitment: SolanaConfirmationCommitment } ), submission = { transactionId } options.onSubmitted?.(submission) - const confirmation = await this.provider.connection.confirmTransaction( - { ...latest, signature: transactionId }, - Commitment - ) - if (confirmation.value.err) - throw new Error( - `Solana collateral submission failed: ${JSON.stringify(confirmation.value.err)}` - ) + await this.waitForConfirmation(transactionId, latest.lastValidBlockHeight) return submission } + + /** HTTP confirmation also works through RPC gateways without a WebSocket endpoint. */ + private async waitForConfirmation( + transactionId: string, + lastValidBlockHeight: number + ): Promise { + const deadline = Date.now() + ConfirmationTimeoutMs, + timeoutError = new Error( + `Solana collateral confirmation timed out for ${transactionId}. Check the signature and depot before resubmitting.` + ) + while (Date.now() < deadline) { + let timer: ReturnType + const confirmed = await Promise.race([ + isSolanaTransactionConfirmed( + this.provider.connection, + transactionId, + lastValidBlockHeight + ), + new Promise((_, reject) => { + timer = setTimeout(() => reject(timeoutError), deadline - Date.now()) + }) + ]).finally(() => clearTimeout(timer)) + if (confirmed) return + await new Promise(resolve => + setTimeout( + resolve, + Math.min( + ConfirmationPollIntervalMs, + Math.max(0, deadline - Date.now()) + ) + ) + ) + } + throw timeoutError + } } diff --git a/packages/sdk-outpost/src/clients/solana/SolanaOutpostAddresses.ts b/packages/sdk-outpost/src/clients/solana/SolanaOutpostAddresses.ts new file mode 100644 index 00000000..71788a71 --- /dev/null +++ b/packages/sdk-outpost/src/clients/solana/SolanaOutpostAddresses.ts @@ -0,0 +1,27 @@ +import { PublicKey } from "@solana/web3.js" + +const SolanaOutpostSeed = { + config: Buffer.from("outpost_config"), + outboundMessageBuffer: Buffer.from("outbound_message_buffer") +} as const + +/** Shared outpost accounts used by distinct reserve and collateral custody paths. */ +export class SolanaOutpostAddresses { + /** Bind address derivation to one verified program deployment. */ + constructor(private readonly programId: PublicKey) {} + + /** Derive the singleton outpost configuration PDA. */ + outpostConfig(): PublicKey { + return this.derive([SolanaOutpostSeed.config]) + } + + /** Derive the singleton outbound message-buffer PDA. */ + outboundMessageBuffer(): PublicKey { + return this.derive([SolanaOutpostSeed.outboundMessageBuffer]) + } + + /** Derive a program-owned account using its producer-defined seeds. */ + protected derive(seeds: Buffer[]): PublicKey { + return PublicKey.findProgramAddressSync(seeds, this.programId)[0] + } +} diff --git a/packages/sdk-outpost/src/clients/solana/SolanaOutpostClient.ts b/packages/sdk-outpost/src/clients/solana/SolanaOutpostClient.ts index 2f98d91b..be334a8e 100644 --- a/packages/sdk-outpost/src/clients/solana/SolanaOutpostClient.ts +++ b/packages/sdk-outpost/src/clients/solana/SolanaOutpostClient.ts @@ -51,7 +51,10 @@ export class SolanaOutpostClient { }, options.provider ) - this.collateral = new SolanaCollateralClient(options.provider, this.liqsolCore) + this.collateral = new SolanaCollateralClient( + options.provider, + this.liqsolCore + ) this.reserves = new SolanaReserveClient(options.provider, this.liqsolCore) this.swaps = new SolanaReserveSwapClient(options.provider, this.liqsolCore) } diff --git a/packages/sdk-outpost/src/clients/solana/SolanaReserveAddresses.ts b/packages/sdk-outpost/src/clients/solana/SolanaReserveAddresses.ts index 464e2ad7..ae81ca84 100644 --- a/packages/sdk-outpost/src/clients/solana/SolanaReserveAddresses.ts +++ b/packages/sdk-outpost/src/clients/solana/SolanaReserveAddresses.ts @@ -1,5 +1,6 @@ import { BN } from "@coral-xyz/anchor" import { PublicKey } from "@solana/web3.js" +import { SolanaOutpostAddresses } from "./SolanaOutpostAddresses.js" import { assertReserveUnsigned64, @@ -7,28 +8,13 @@ import { } 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]) - } - +export class SolanaReserveAddresses extends SolanaOutpostAddresses { /** Derive a reserve account PDA. */ reserve(identity: OutpostReserveIdentity): PublicKey { return this.reserveAddress(SolanaReserveSeed.reserve, identity) @@ -40,17 +26,10 @@ export class SolanaReserveAddresses { } /** Convert an SDK reserve integer to Anchor's u64 representation. */ - unsigned64( - value: OutpostReserveIdentity["tokenCode"], - field: string - ): BN { + 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 diff --git a/packages/sdk-outpost/src/clients/solana/SolanaReserveSwapClient.ts b/packages/sdk-outpost/src/clients/solana/SolanaReserveSwapClient.ts index 2622e6d1..6f1e761f 100644 --- a/packages/sdk-outpost/src/clients/solana/SolanaReserveSwapClient.ts +++ b/packages/sdk-outpost/src/clients/solana/SolanaReserveSwapClient.ts @@ -21,12 +21,12 @@ import { } from "../../reserves/index.js" import { SolanaReserveAddresses } from "./SolanaReserveAddresses.js" -const ConfirmationCommitment = "confirmed", - ConfirmationPollIntervalMs = 1_500, - SolanaConfirmationStatus = { - confirmed: "confirmed", - finalized: "finalized" - } as const, +import { + isSolanaTransactionConfirmed, + SolanaConfirmationCommitment +} from "../../util/SolanaConfirmation.js" + +const ConfirmationPollIntervalMs = 1_500, SwapDepositLog = /opp_outpost: SwapDeposit id=(\d+)\b/ /** Reserve-swap writes and balance reads for one verified Solana outpost. */ @@ -117,7 +117,7 @@ export class SolanaReserveSwapClient { return BigInt( await this.provider.connection.getBalance( owner, - ConfirmationCommitment + SolanaConfirmationCommitment ) ) } @@ -135,12 +135,12 @@ export class SolanaReserveSwapClient { ), account = await this.provider.connection.getAccountInfo( tokenAccount, - ConfirmationCommitment + SolanaConfirmationCommitment ) if (account == null) return 0n const balance = await this.provider.connection.getTokenAccountBalance( tokenAccount, - ConfirmationCommitment + SolanaConfirmationCommitment ) return BigInt(balance.value.amount) } @@ -179,19 +179,18 @@ export class SolanaReserveSwapClient { ): Promise { const connection = this.provider.connection, latestBlockhash = await connection.getLatestBlockhash( - ConfirmationCommitment + SolanaConfirmationCommitment ), transaction = new Transaction({ feePayer: this.assertWallet(), blockhash: latestBlockhash.blockhash, lastValidBlockHeight: latestBlockhash.lastValidBlockHeight }).add(instruction), - signedTransaction = await this.provider.wallet.signTransaction( - transaction - ), + signedTransaction = + await this.provider.wallet.signTransaction(transaction), transactionId = await connection.sendRawTransaction( signedTransaction.serialize(), - { preflightCommitment: ConfirmationCommitment } + { preflightCommitment: SolanaConfirmationCommitment } ), confirmedTransaction = await this.waitForConfirmedTransaction( transactionId, @@ -209,43 +208,26 @@ export class SolanaReserveSwapClient { 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)}` + confirmed = await isSolanaTransactionConfirmed( + connection, + transactionId, + lastValidBlockHeight ) - } - - const confirmed = - status?.confirmationStatus === SolanaConfirmationStatus.confirmed || - status?.confirmationStatus === SolanaConfirmationStatus.finalized if (confirmed) { const confirmedTransaction = await connection.getTransaction( transactionId, { - commitment: ConfirmationCommitment, + commitment: SolanaConfirmationCommitment, 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 + await new Promise(resolve => + setTimeout(resolve, ConfirmationPollIntervalMs) ) + return this.waitForConfirmedTransaction(transactionId, lastValidBlockHeight) } /** Parse the canonical deposit id logged by `request_swap*`. */ diff --git a/packages/sdk-outpost/src/collateral/Types.ts b/packages/sdk-outpost/src/collateral/Types.ts index 822a1d39..290ce0c0 100644 --- a/packages/sdk-outpost/src/collateral/Types.ts +++ b/packages/sdk-outpost/src/collateral/Types.ts @@ -2,8 +2,11 @@ import type { SystemContracts } from "@wireio/sdk-core" /** Native custody units, never reserve-normalized amounts. */ export interface OperatorCollateralRequest { + /** Generated depot role; producers, batch operators and underwriters only. */ operatorType: SystemContracts.SysioOpregOperatortype + /** Positive u64 code for the configured native custody asset. */ tokenCode: bigint + /** Positive raw wei or lamports, without reserve normalization. */ amount: bigint /** Latest depot bucket balance, including locked and queued collateral. */ depotBalance: bigint @@ -13,16 +16,20 @@ export interface OperatorCollateralRequest { /** A source submission is not proof that the depot accepted the collateral. */ export interface OperatorCollateralSubmission { + /** External-chain hash or signature, never a depot withdrawal queue ID. */ transactionId: string } /** Retain the source identifier before confirmation can time out. */ export interface OperatorCollateralSubmissionOptions { + /** Persist the receipt immediately after broadcast; never resubmit automatically on failure. */ onSubmitted?: (submission: OperatorCollateralSubmission) => void } /** Operations supported by the selected producer artifact suite. */ export interface OperatorCollateralCapabilities { + /** Whether this artifact suite exposes native operator collateral deposits. */ nativeDeposit: boolean + /** Whether this artifact suite exposes a public native operator withdrawal request. */ nativeWithdrawal: boolean } diff --git a/packages/sdk-outpost/src/util/SolanaConfirmation.ts b/packages/sdk-outpost/src/util/SolanaConfirmation.ts new file mode 100644 index 00000000..4a66c768 --- /dev/null +++ b/packages/sdk-outpost/src/util/SolanaConfirmation.ts @@ -0,0 +1,37 @@ +import type { Connection } from "@solana/web3.js" + +/** Source-chain commitment used by outpost custody submissions. */ +export const SolanaConfirmationCommitment = "confirmed" + +const SolanaConfirmationStatus = { + confirmed: "confirmed", + finalized: "finalized" +} as const + +/** Read one signature over HTTP; propagate RPC failures and reject terminal chain errors. */ +export async function isSolanaTransactionConfirmed( + connection: Connection, + transactionId: string, + lastValidBlockHeight: number +): Promise { + const [response, blockHeight] = await Promise.all([ + connection.getSignatureStatuses([transactionId], { + searchTransactionHistory: true + }), + connection.getBlockHeight(SolanaConfirmationCommitment) + ]), + status = response.value[0] + + if (status?.err != null) + throw new Error( + `Solana transaction ${transactionId} failed: ${JSON.stringify(status.err)}` + ) + if (status == null && blockHeight > lastValidBlockHeight) + throw new Error( + `Solana signature ${transactionId} was not recorded before its blockhash expired. Check the signature and depot before resubmitting.` + ) + return ( + status?.confirmationStatus === SolanaConfirmationStatus.confirmed || + status?.confirmationStatus === SolanaConfirmationStatus.finalized + ) +} diff --git a/packages/sdk-outpost/tests/clients/OutpostClient.test.ts b/packages/sdk-outpost/tests/clients/OutpostClient.test.ts index 7c73b14a..464d9028 100644 --- a/packages/sdk-outpost/tests/clients/OutpostClient.test.ts +++ b/packages/sdk-outpost/tests/clients/OutpostClient.test.ts @@ -24,6 +24,10 @@ describe("OutpostClient", () => { expect(typedClient.profile).toBe(profile) expect(typedClient.reserves).toBeDefined() + expect(typedClient.collateral.capabilities).toEqual({ + nativeDeposit: true, + nativeWithdrawal: true + }) }) it("preserves the precise Solana client type", async () => { @@ -39,5 +43,9 @@ describe("OutpostClient", () => { expect(typedClient.profile).toBe(profile) expect(typedClient.reserves).toBeDefined() + expect(typedClient.collateral.capabilities).toEqual({ + nativeDeposit: true, + nativeWithdrawal: false + }) }) }) diff --git a/packages/sdk-outpost/tests/clients/ethereum/EthereumCollateralClient.test.ts b/packages/sdk-outpost/tests/clients/ethereum/EthereumCollateralClient.test.ts index dc500289..8e31a9aa 100644 --- a/packages/sdk-outpost/tests/clients/ethereum/EthereumCollateralClient.test.ts +++ b/packages/sdk-outpost/tests/clients/ethereum/EthereumCollateralClient.test.ts @@ -7,18 +7,18 @@ import { } from "@wireio/sdk-outpost" /** Observe custody writes without making network requests. */ -function fixture() { +function createCollateralFixture() { const signer = Wallet.createRandom(), wait = jest.fn(async () => ({ status: 1 })), - method = () => + createMethod = () => Object.assign( jest.fn(async () => ({ hash: "0x1234", wait })), { staticCall: jest.fn(async (): Promise => undefined) } ), registry = { nativeTokenCode: jest.fn(async () => 1n), - deposit: method(), - withdraw: method() + deposit: createMethod(), + withdraw: createMethod() }, client = new EthereumCollateralClient( registry as unknown as OperatorRegistry, @@ -37,7 +37,8 @@ function fixture() { describe("EthereumCollateralClient", () => { it("preserves raw wei, compresses the signer's key and records submission before confirmation", async () => { - const { client, registry, request, wait, signer } = fixture(), + const { client, registry, request, wait, signer } = + createCollateralFixture(), onSubmitted = jest.fn(() => expect(wait).not.toHaveBeenCalled()) await expect( client.depositNative(request, { onSubmitted }) @@ -55,7 +56,7 @@ describe("EthereumCollateralClient", () => { }) it("rejects token/key mismatches and aggregate overflow before custody moves", async () => { - const { client, registry, request } = fixture() + const { client, registry, request } = createCollateralFixture() await expect( client.depositNative({ ...request, tokenCode: 2n }) ).rejects.toThrow("native collateral") @@ -75,7 +76,7 @@ describe("EthereumCollateralClient", () => { }) it("sends a withdrawal request without presenting the source hash as a queue id", async () => { - const { client, registry, request, signer } = fixture() + const { client, registry, request, signer } = createCollateralFixture() await expect( client.requestNativeWithdrawal({ ...request, @@ -93,12 +94,40 @@ describe("EthereumCollateralClient", () => { }) it("retains the submitted hash even when confirmation fails", async () => { - const { client, request, wait } = fixture(), + const { client, request, wait } = createCollateralFixture(), onSubmitted = jest.fn() - wait.mockRejectedValueOnce(new Error("RPC lost") as never) + wait.mockRejectedValueOnce(new Error("RPC lost")) await expect( client.depositNative(request, { onSubmitted }) ).rejects.toThrow("RPC lost") expect(onSubmitted).toHaveBeenCalledWith({ transactionId: "0x1234" }) }) + + it("does not broadcast or report submission when preflight fails", async () => { + const { client, registry, request } = createCollateralFixture(), + onSubmitted = jest.fn() + registry.deposit.staticCall.mockRejectedValueOnce( + new Error("Route disabled") + ) + await expect( + client.depositNative(request, { onSubmitted }) + ).rejects.toThrow("Route disabled") + expect(registry.deposit).not.toHaveBeenCalled() + expect(onSubmitted).not.toHaveBeenCalled() + }) + + it("retains a withdrawal receipt when confirmation fails without duplicating the request", async () => { + const { client, registry, request, wait } = createCollateralFixture(), + onSubmitted = jest.fn() + wait.mockRejectedValueOnce(new Error("RPC lost")) + await expect( + client.requestNativeWithdrawal( + { ...request, depotBalance: request.amount }, + { onSubmitted } + ) + ).rejects.toThrow("RPC lost") + expect(onSubmitted).toHaveBeenCalledWith({ transactionId: "0x1234" }) + expect(registry.withdraw.staticCall).toHaveBeenCalledTimes(1) + expect(registry.withdraw).toHaveBeenCalledTimes(1) + }) }) diff --git a/packages/sdk-outpost/tests/clients/solana/SolanaCollateralClient.test.ts b/packages/sdk-outpost/tests/clients/solana/SolanaCollateralClient.test.ts index 83a73d96..a821044a 100644 --- a/packages/sdk-outpost/tests/clients/solana/SolanaCollateralClient.test.ts +++ b/packages/sdk-outpost/tests/clients/solana/SolanaCollateralClient.test.ts @@ -1,79 +1,225 @@ -import { Program, BorshInstructionCoder, type BN } from "@coral-xyz/anchor" +import { Program, BorshInstructionCoder } from "@coral-xyz/anchor" import { liqsolCoreIdl, type LiqsolCore } from "@wireio/outpost-solana-artifacts" import { SystemContracts } from "@wireio/sdk-core" import { SolanaCollateralClient } from "@wireio/sdk-outpost" -import { PublicKey, SystemProgram } from "@solana/web3.js" +import { PublicKey, SystemProgram, type SignatureStatus } from "@solana/web3.js" import { createOutpostDeploymentProfileFixture, createSolanaProviderFixture } from "../../Fixtures.js" +const SubmittedSignature = "4".repeat(64), + LastValidBlockHeight = 99, + ConfirmationTimeoutMs = 120_000, + ConfirmationPollIntervalMs = 1_000, + ConfirmationStatus = { + processed: "processed", + confirmed: "confirmed", + finalized: "finalized" + } as const, + Request = { + operatorType: SystemContracts.SysioOpregOperatortype.OPERATOR_TYPE_BATCH, + tokenCode: 1n, + amount: 1n, + depotBalance: 0n + } + +/** Generated Anchor instruction's decoded payload used by the custody assertion. */ interface DecodedCollateralArguments { - amount: BN + /** Native lamports as declared by the generated deposit method. */ + amount: Parameters["methods"]["deposit"]>[2] +} + +/** Create a signed local transaction fixture; every network operation is mocked. */ +function createCollateralFixture() { + const profile = createOutpostDeploymentProfileFixture(), + provider = createSolanaProviderFixture(profile), + program = new Program( + { ...liqsolCoreIdl, address: profile.solana.programs.liqsolCore.address }, + provider + ), + client = new SolanaCollateralClient(provider, program), + send = jest + .spyOn(provider.connection, "sendRawTransaction") + .mockResolvedValue(SubmittedSignature), + status = jest.spyOn(provider.connection, "getSignatureStatuses"), + height = jest + .spyOn(provider.connection, "getBlockHeight") + .mockResolvedValue(LastValidBlockHeight), + websocket = jest.spyOn(provider.connection, "onSignature"), + onSubmitted = jest.fn(() => expect(status).not.toHaveBeenCalled()) + jest.spyOn(provider.connection, "getLatestBlockhash").mockResolvedValue({ + blockhash: "1".repeat(32), + lastValidBlockHeight: LastValidBlockHeight + }) + return { + provider, + program, + client, + send, + status, + height, + websocket, + onSubmitted + } +} + +/** Construct typed RPC evidence without reaching a running validator. */ +function createStatus( + confirmationStatus: SignatureStatus["confirmationStatus"] +): SignatureStatus { + return { slot: 1, confirmations: 1, confirmationStatus, err: null } } describe("SolanaCollateralClient", () => { - it("records the source signature before confirmation and reports a failed instruction", async () => { - const profile = createOutpostDeploymentProfileFixture(), - provider = createSolanaProviderFixture(profile), - program = new Program( - { - ...liqsolCoreIdl, - address: profile.solana.programs.liqsolCore.address - }, - provider - ), - client = new SolanaCollateralClient(provider, program), - signature = "4".repeat(64), - confirm = jest - .spyOn(provider.connection, "confirmTransaction") - .mockResolvedValue({ - context: { slot: 1 }, - value: { err: { InstructionError: [0, "InvalidArgument"] } } - }), - onSubmitted = jest.fn(() => expect(confirm).not.toHaveBeenCalled()) - jest - .spyOn(provider.connection, "getLatestBlockhash") + afterEach(() => { + jest.useRealTimers() + jest.restoreAllMocks() + }) + + it.each([ConfirmationStatus.confirmed, ConfirmationStatus.finalized])( + "accepts %s HTTP evidence without a WebSocket or duplicate send", + async confirmationStatus => { + jest.useFakeTimers() + const { client, status, height, send, websocket, onSubmitted } = + createCollateralFixture() + status.mockResolvedValue({ + context: { slot: 1 }, + value: [createStatus(confirmationStatus)] + }) + // A recorded signature remains valid after its submission blockhash expires. + height.mockResolvedValue(LastValidBlockHeight + 1) + await expect( + client.depositNative(Request, { onSubmitted }) + ).resolves.toEqual({ transactionId: SubmittedSignature }) + expect(onSubmitted).toHaveBeenCalledWith({ + transactionId: SubmittedSignature + }) + expect(send).toHaveBeenCalledTimes(1) + expect(websocket).not.toHaveBeenCalled() + expect(jest.getTimerCount()).toBe(0) + } + ) + + it("waits for a processed signature to confirm without rebroadcasting", async () => { + jest.useFakeTimers() + const { client, status, send, onSubmitted } = createCollateralFixture() + status + .mockResolvedValueOnce({ + context: { slot: 1 }, + value: [createStatus(ConfirmationStatus.processed)] + }) .mockResolvedValue({ - blockhash: "1".repeat(32), - lastValidBlockHeight: 99 + context: { slot: 2 }, + value: [createStatus(ConfirmationStatus.confirmed)] }) - jest - .spyOn(provider.connection, "sendRawTransaction") - .mockResolvedValue(signature) - await expect( - client.depositNative( + const result = client.depositNative(Request, { onSubmitted }) + await jest.advanceTimersByTimeAsync(ConfirmationPollIntervalMs) + await expect(result).resolves.toEqual({ transactionId: SubmittedSignature }) + expect(status).toHaveBeenCalledTimes(2) + expect(send).toHaveBeenCalledTimes(1) + expect(onSubmitted).toHaveBeenCalledTimes(1) + expect(jest.getTimerCount()).toBe(0) + }) + + it("preserves the signature on instruction error and expiry", async () => { + const { client, status, height, onSubmitted, send } = + createCollateralFixture() + status.mockResolvedValue({ + context: { slot: 1 }, + value: [ { - operatorType: - SystemContracts.SysioOpregOperatortype.OPERATOR_TYPE_BATCH, - tokenCode: 1n, - amount: 1n, - depotBalance: 0n - }, - { onSubmitted } - ) + ...createStatus(ConfirmationStatus.processed), + err: { InstructionError: [0, "InvalidArgument"] } + } + ] + }) + await expect( + client.depositNative(Request, { onSubmitted }) ).rejects.toThrow("InvalidArgument") - expect(onSubmitted).toHaveBeenCalledWith({ transactionId: signature }) - expect(confirm).toHaveBeenCalledWith( - { blockhash: "1".repeat(32), lastValidBlockHeight: 99, signature }, - "confirmed" + expect(onSubmitted).toHaveBeenCalledWith({ + transactionId: SubmittedSignature + }) + status + .mockClear() + .mockResolvedValue({ context: { slot: 1 }, value: [null] }) + height.mockResolvedValue(LastValidBlockHeight + 1) + await expect( + client.depositNative(Request, { onSubmitted }) + ).rejects.toThrow("blockhash expired") + expect(onSubmitted).toHaveBeenCalledTimes(2) + expect(send).toHaveBeenCalledTimes(2) + }) + + it("propagates an RPC error after saving the signature and clears its timeout", async () => { + jest.useFakeTimers() + const { client, status, send, onSubmitted } = createCollateralFixture(), + error = new Error("RPC unavailable") + status.mockRejectedValue(error) + await expect(client.depositNative(Request, { onSubmitted })).rejects.toBe( + error ) + expect(onSubmitted).toHaveBeenCalledWith({ + transactionId: SubmittedSignature + }) + expect(send).toHaveBeenCalledTimes(1) + expect(jest.getTimerCount()).toBe(0) + }) + + it("bounds a stalled HTTP call and stops polling after timeout", async () => { + jest.useFakeTimers() + const { client, status, send, onSubmitted } = createCollateralFixture() + status.mockImplementation(() => new Promise(() => {})) + const result = expect( + client.depositNative(Request, { onSubmitted }) + ).rejects.toThrow("timed out") + await jest.advanceTimersByTimeAsync(ConfirmationTimeoutMs) + await result + expect(onSubmitted).toHaveBeenCalledWith({ + transactionId: SubmittedSignature + }) + expect(send).toHaveBeenCalledTimes(1) + expect(status).toHaveBeenCalledTimes(1) + expect(jest.getTimerCount()).toBe(0) + }) + + it("stops polling a pending signature at the deadline without a duplicate deposit", async () => { + jest.useFakeTimers() + const { client, status, send, onSubmitted } = createCollateralFixture() + status.mockResolvedValue({ + context: { slot: 1 }, + value: [createStatus(ConfirmationStatus.processed)] + }) + const result = expect( + client.depositNative(Request, { onSubmitted }) + ).rejects.toThrow("timed out") + await jest.advanceTimersByTimeAsync(ConfirmationTimeoutMs) + await result + const reads = status.mock.calls.length + await jest.advanceTimersByTimeAsync(ConfirmationPollIntervalMs) + expect(status).toHaveBeenCalledTimes(reads) + expect(send).toHaveBeenCalledTimes(1) + expect(onSubmitted).toHaveBeenCalledTimes(1) + expect(jest.getTimerCount()).toBe(0) + }) + + it("does not report submission when the wallet rejects signing", async () => { + const { client, provider, send, onSubmitted } = createCollateralFixture() + jest + .spyOn(provider.wallet, "signTransaction") + .mockRejectedValue(new Error("Wallet declined")) + await expect( + client.depositNative(Request, { onSubmitted }) + ).rejects.toThrow("Wallet declined") + expect(send).not.toHaveBeenCalled() + expect(onSubmitted).not.toHaveBeenCalled() }) it("encodes exact lamports with the producer-defined custody accounts and no withdrawal capability", async () => { - const profile = createOutpostDeploymentProfileFixture(), - provider = createSolanaProviderFixture(profile), - program = new Program( - { - ...liqsolCoreIdl, - address: profile.solana.programs.liqsolCore.address - }, - provider - ), - client = new SolanaCollateralClient(provider, program), + const { client, provider, program } = createCollateralFixture(), amount = 9_007_199_254_740_993n, instruction = await client.createNativeDepositInstruction({ operatorType: @@ -92,7 +238,22 @@ describe("SolanaCollateralClient", () => { )[0], decoded = new BorshInstructionCoder(liqsolCoreIdl).decode( instruction.data - )! + )!, + sharedSeeds = [ + "outpost_config", + "operator_registry", + "outbound_message_buffer", + "outpost_vault" + ] + sharedSeeds.forEach(seed => { + const address = PublicKey.findProgramAddressSync( + [Buffer.from(seed)], + program.programId + )[0] + expect(instruction.keys.some(key => key.pubkey.equals(address))).toBe( + true + ) + }) expect(decoded.name).toBe("deposit") expect((decoded.data as DecodedCollateralArguments).amount.toString()).toBe( amount.toString() diff --git a/packages/sdk-outpost/tests/clients/solana/SolanaOutpostClient.test.ts b/packages/sdk-outpost/tests/clients/solana/SolanaOutpostClient.test.ts index 55319dd1..39794e6a 100644 --- a/packages/sdk-outpost/tests/clients/solana/SolanaOutpostClient.test.ts +++ b/packages/sdk-outpost/tests/clients/solana/SolanaOutpostClient.test.ts @@ -173,7 +173,7 @@ describe("SolanaOutpostClient", () => { jest.spyOn(provider.connection, "getBlockHeight").mockResolvedValue(10) await expect(client.swaps.requestNative(reserveSwapRequest)).rejects.toThrow( - `Solana reserve swap ${SubmittedSignature} failed` + `Solana transaction ${SubmittedSignature} failed` ) }) From 9cd81836059a12c20cc6e05a539abcf06bb40173 Mon Sep 17 00:00:00 2001 From: Josh Glogau <70033421+joshglogau@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:27:54 -0400 Subject: [PATCH 3/3] chore(sdk-outpost): clean up readme --- README.md | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/README.md b/README.md index be1c60d0..72ed0621 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,6 @@ A monorepo containing shared TypeScript libraries for Wire applications, providing cross-platform utilities for logging, type guards, async helpers, blockchain SDK primitives, and protobuf code-generation tooling. -The operator collateral addition in `sdk-outpost` exposes native Ethereum -deposit/withdraw-request and native Solana deposit through verified clients. -It validates exact custody units and depot capacity, and retains source receipts -before confirmation. Solana collateral confirms through bounded HTTP polling, -including RPC gateways without WebSockets. See [the SDK collateral contract](packages/sdk-outpost/README.md#operator-collateral). -This source addition requires the normal release workflow before npm consumers -can use it; it does not add Solana withdrawal or change deployed contracts. - ## Packages | Package | Description | npm | @@ -22,13 +14,6 @@ can use it; it does not add Solana withdrawal or change deployed contracts. | [`@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 |