Skip to content
Merged
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: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ All generated or modified code **must** include JSDoc comments (`/** ... */`), c
- `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 directly from exact npm package versions owned by `wire-ethereum` and `wire-solana`; never regenerate them here or replace them with committed sibling links.
- `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 and swap execution. Staking remains outside this package until its dedicated migration.
- `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.
Expand Down
27 changes: 27 additions & 0 deletions packages/sdk-outpost/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,33 @@ 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
Expand Down
1 change: 1 addition & 0 deletions packages/sdk-outpost/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"@solana/web3.js": "^1.98.4",
"@wireio/outpost-ethereum-artifacts": "0.2.2",
"@wireio/outpost-solana-artifacts": "0.2.1",
"@wireio/sdk-core": "workspace:*",
"ethers": "^6.15.0",
"ts-pattern": "^5.9.0",
"zod": "^4.4.3"
Expand Down
4 changes: 3 additions & 1 deletion packages/sdk-outpost/src/artifacts/Compatibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,8 +121,10 @@ export function assertOutpostArtifactCompatibility(
match(family)
.with(OutpostChainFamily.ethereum, () => {
Object.values(EthereumContractName).forEach(contractName => {
const contract = profile.ethereum.contracts[contractName]
if (contract == null) return
assertInterfaceDigest(
profile.ethereum.contracts[contractName].abiSha256,
contract.abiSha256,
OutpostArtifactManifests.ethereum.contracts[contractName].abiSha256,
`Ethereum ${contractName} ABI`
)
Expand Down
256 changes: 256 additions & 0 deletions packages/sdk-outpost/src/clients/ethereum/EthereumNodeOwnerClient.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<EthereumNodeOwnerSlotBalance[]> {
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<EthereumNodeOwnerCommitSubmission> {
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}.`)
})
}
}
29 changes: 29 additions & 0 deletions packages/sdk-outpost/src/clients/ethereum/EthereumOutpostClient.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
BAR__factory,
OPPInbound__factory,
OPP__factory,
OperatorRegistry__factory,
Expand All @@ -16,6 +17,7 @@ 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 {
Expand Down Expand Up @@ -47,6 +49,12 @@ export class EthereumOutpostClient {
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. */
Expand All @@ -55,6 +63,18 @@ export class EthereumOutpostClient {
/** 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
Expand All @@ -64,6 +84,15 @@ export class EthereumOutpostClient {
contract<T extends EthereumContractName>(name: T): EthereumContractMap[T] {
const { connection, profile } = this.options,
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 BAR__factory.connect(bar.address, connection)
})
.with(EthereumContractName.OPP, () =>
OPP__factory.connect(
profile.ethereum.contracts[EthereumContractName.OPP].address,
Expand Down
3 changes: 3 additions & 0 deletions packages/sdk-outpost/src/clients/ethereum/Types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type {
BAR,
OPP,
OPPInbound,
OperatorRegistry,
Expand All @@ -19,6 +20,8 @@ export interface EthereumOutpostClientOptions {

/** 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. */
Expand Down
Loading
Loading