Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,8 @@ 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.
- 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
Expand Down
7 changes: 0 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,6 @@ A monorepo containing shared TypeScript libraries for Wire applications, providi
| [`@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 |
Expand Down
37 changes: 37 additions & 0 deletions packages/sdk-outpost/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,43 @@ 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;
a version bump alone does not publish the API.

| 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.

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
credited rewards use Wire `claimpay` / `claimuwfee`, not these custody clients.
Private keys and mutable endpoint catalogs remain caller-owned.

## Install

```sh
Expand Down
113 changes: 113 additions & 0 deletions packages/sdk-outpost/src/clients/ethereum/EthereumCollateralClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import type { OperatorRegistry } from "@wireio/outpost-ethereum-artifacts"
import {
computeAddress,
getAddress,
SigningKey,
type Provider,
type Signer
} from "ethers"
import {
assertOperatorCollateralRequest,
type OperatorCollateralCapabilities,
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
} satisfies OperatorCollateralCapabilities)

/** Read the registry's configured native code instead of guessing a token identity. */
async nativeTokenCode(): Promise<bigint> {
return this.registry.nativeTokenCode()
}

/** Escrow raw native units; the caller must separately observe depot credit or refund. */
async depositNative(
request: OperatorCollateralRequest,
options: OperatorCollateralSubmissionOptions = {}
): Promise<OperatorCollateralSubmission> {
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<OperatorCollateralSubmission> {
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<string> {
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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -43,6 +44,10 @@ 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
Expand All @@ -59,6 +64,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

Expand Down
1 change: 1 addition & 0 deletions packages/sdk-outpost/src/clients/ethereum/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export * from "./EthereumReserveSwapClient.js"
export * from "./EthereumReserveClient.js"
export * from "./EthereumNodeOwnerClient.js"
export * from "./Types.js"
export * from "./EthereumCollateralClient.js"
Original file line number Diff line number Diff line change
@@ -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)
])
}
}
131 changes: 131 additions & 0 deletions packages/sdk-outpost/src/clients/solana/SolanaCollateralClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { BN, type AnchorProvider, type Program } from "@coral-xyz/anchor"
import {
SystemProgram,
Transaction,
type TransactionInstruction
} from "@solana/web3.js"
import type { LiqsolCore } from "@wireio/outpost-solana-artifacts"
import {
assertOperatorCollateralRequest,
type OperatorCollateralCapabilities,
type OperatorCollateralRequest,
type OperatorCollateralSubmission,
type OperatorCollateralSubmissionOptions
} from "../../collateral/index.js"

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<LiqsolCore>
) {
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(
request: OperatorCollateralRequest
): Promise<TransactionInstruction> {
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())
return this.program.methods
.deposit(
request.operatorType,
tokenCode,
new BN(request.amount.toString())
)
.accounts({
depositor,
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()
}

/** Submit native custody and retain its signature before confirmation. Depot acceptance is separate. */
async depositNative(
request: OperatorCollateralRequest,
options: OperatorCollateralSubmissionOptions = {}
): Promise<OperatorCollateralSubmission> {
const instruction = await this.createNativeDepositInstruction(request),
latest = await this.provider.connection.getLatestBlockhash(
SolanaConfirmationCommitment
),
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: SolanaConfirmationCommitment }
),
submission = { transactionId }
options.onSubmitted?.(submission)
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<void> {
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<typeof setTimeout>
const confirmed = await Promise.race([
isSolanaTransactionConfirmed(
this.provider.connection,
transactionId,
lastValidBlockHeight
),
new Promise<never>((_, 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
}
}
Loading
Loading