diff --git a/CLAUDE.md b/CLAUDE.md index fca5df6..dc1f662 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/packages/sdk-outpost/README.md b/packages/sdk-outpost/README.md index 5b0e5d3..c449a31 100644 --- a/packages/sdk-outpost/README.md +++ b/packages/sdk-outpost/README.md @@ -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 diff --git a/packages/sdk-outpost/package.json b/packages/sdk-outpost/package.json index 16666e1..5e69cac 100644 --- a/packages/sdk-outpost/package.json +++ b/packages/sdk-outpost/package.json @@ -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" diff --git a/packages/sdk-outpost/src/artifacts/Compatibility.ts b/packages/sdk-outpost/src/artifacts/Compatibility.ts index 156395d..b398c1e 100644 --- a/packages/sdk-outpost/src/artifacts/Compatibility.ts +++ b/packages/sdk-outpost/src/artifacts/Compatibility.ts @@ -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` ) diff --git a/packages/sdk-outpost/src/clients/ethereum/EthereumNodeOwnerClient.ts b/packages/sdk-outpost/src/clients/ethereum/EthereumNodeOwnerClient.ts new file mode 100644 index 0000000..eaeaf19 --- /dev/null +++ b/packages/sdk-outpost/src/clients/ethereum/EthereumNodeOwnerClient.ts @@ -0,0 +1,256 @@ +import type { BAR } from "@wireio/outpost-ethereum-artifacts" +import { IERC1155__factory } from "@wireio/outpost-ethereum-artifacts" +import { KeyType, type Name, type PublicKey } from "@wireio/sdk-core" +import { + ZeroAddress, + computeAddress, + getAddress, + getBigInt, + getBytes, + hexlify, + type BigNumberish, + type BytesLike, + type EventLog, + type Log, + type Provider, + type Signer +} from "ethers" +import { match } from "ts-pattern" + +import { assertEthereumSigner } from "./Connection.js" + +const ConfirmationCount = 1, + NodeCommittedEventName = "NodeCommitted", + UncompressedPublicKeyByteLength = 65, + UncompressedPublicKeyPrefix = 4 + +/** BAR WireKey numeric variants accepted for node-owner account authority. */ +enum NodeOwnerWireKeyType { + K1 = 1, + R1 = 2, + EM = 4, + ED = 5 +} + +/** WireNodes ERC-1155 token ids accepted by BAR as node-owner tiers. */ +export enum EthereumNodeOwnerTier { + T1 = 1, + T2 = 2, + T3 = 3 +} + +const DefaultNodeOwnerTiers = [ + EthereumNodeOwnerTier.T1, + EthereumNodeOwnerTier.T2, + EthereumNodeOwnerTier.T3 +] as const + +/** One owned WireNodes tier returned from the canonical ERC-1155 contract. */ +export interface EthereumNodeOwnerSlotBalance { + /** WireNodes token id and node-owner tier. */ + tokenId: EthereumNodeOwnerTier + /** Number of units held by the queried owner. */ + balance: bigint + /** Canonical WireNodes contract configured by BAR. */ + tokenContractAddress: string +} + +/** Inputs required to escrow a WireNodes unit and register its owner. */ +export interface EthereumNodeOwnerCommitRequest { + /** WireNodes token id and node-owner tier to commit. */ + tokenId: EthereumNodeOwnerTier + /** Canonical Wire account name to create or register. */ + wireAccountName: Name + /** Wire account owner/active authority. */ + wirePublicKey: PublicKey + /** Uncompressed SEC1 secp256k1 public key belonging to the EVM signer. */ + depositorPublicKey: BytesLike +} + +/** Canonical `NodeCommitted` fields emitted by BAR. */ +export interface EthereumNodeCommittedEvent { + /** EVM owner that committed the token. */ + owner: string + /** WireNodes token id and node-owner tier. */ + tokenId: EthereumNodeOwnerTier + /** Canonical WireNodes contract from which BAR pulled custody. */ + tokenContractAddress: string + /** Wire account submitted for registration. */ + wireAccountName: string +} + +/** Confirmed node-owner registration submission. */ +export interface EthereumNodeOwnerCommitSubmission { + /** BAR commit transaction hash. */ + transactionId: string + /** ERC-1155 approval transaction hash when approval was required. */ + approvalTransactionId?: string + /** Confirmed BAR event proving the submitted registration. */ + committed: EthereumNodeCommittedEvent +} + +/** Node-owner slot reads, approval, and registration for one verified outpost. */ +export class EthereumNodeOwnerClient { + /** Bind node-owner operations to a generated BAR contract. */ + constructor( + private readonly bar: BAR, + private readonly connection: Provider | Signer + ) {} + + /** Resolve the governance-configured canonical WireNodes contract. */ + async canonicalTokenContractAddress(): Promise { + const address = getAddress(await this.bar.wireNodesContract()) + if (address === ZeroAddress) { + throw new Error("BAR has no canonical WireNodes contract configured.") + } + return address + } + + /** Return non-zero WireNodes balances for the requested owner and tiers. */ + async ownedSlots( + owner: string, + tokenIds: readonly EthereumNodeOwnerTier[] = DefaultNodeOwnerTiers + ): Promise { + const normalizedOwner = getAddress(owner), + tokenContractAddress = await this.canonicalTokenContractAddress(), + token = IERC1155__factory.connect(tokenContractAddress, this.connection), + balances = await Promise.all( + tokenIds.map(async tokenId => ({ + tokenId, + balance: getBigInt(await token.balanceOf(normalizedOwner, tokenId)), + tokenContractAddress + })) + ) + + return balances.filter(({ balance }) => balance > 0n) + } + + /** Approve BAR when needed, commit one WireNodes unit, and return its event. */ + async commit( + request: EthereumNodeOwnerCommitRequest + ): Promise { + const signer = this.assertSigner(), + owner = getAddress(await signer.getAddress()), + depositorPublicKey = this.assertDepositorPublicKey( + request.depositorPublicKey, + owner + ), + wireAccountName = this.canonicalAccountName( + request.wireAccountName.toString() + ), + tokenContractAddress = await this.canonicalTokenContractAddress(), + token = IERC1155__factory.connect(tokenContractAddress, signer), + barAddress = await this.bar.getAddress(), + approved = await token.isApprovedForAll(owner, barAddress) + + let approvalTransactionId: string | undefined + if (!approved) { + const approval = await token.setApprovalForAll(barAddress, true) + approvalTransactionId = approval.hash + await approval.wait(ConfirmationCount) + } + + const transaction = await this.bar.commitNode( + request.tokenId, + wireAccountName, + this.wireKey(request.wirePublicKey), + depositorPublicKey + ), + receipt = await transaction.wait(ConfirmationCount) + + return { + transactionId: transaction.hash, + approvalTransactionId, + committed: EthereumNodeOwnerClient.committedEvent(receipt?.logs) + } + } + + /** Normalize the canonical `NodeCommitted` event from a BAR receipt. */ + static committedEvent( + events: readonly (EventLog | Log)[] | undefined + ): EthereumNodeCommittedEvent { + const event = events?.find( + candidate => + "eventName" in candidate && + candidate.eventName === NodeCommittedEventName + ), + arguments_ = event != null && "args" in event ? event.args : undefined, + owner = arguments_?.[0], + tokenId = arguments_?.[1], + tokenContractAddress = arguments_?.[2], + wireAccountName = arguments_?.[3] + + if ( + owner == null || + tokenId == null || + tokenContractAddress == null || + wireAccountName == null + ) { + throw new Error("Confirmed BAR transaction did not emit NodeCommitted.") + } + return { + owner: getAddress(owner), + tokenId: EthereumNodeOwnerClient.nodeOwnerTier(tokenId), + tokenContractAddress: getAddress(tokenContractAddress), + wireAccountName + } + } + + /** Require a connected EVM signer for node-owner writes. */ + private assertSigner(): Signer { + return assertEthereumSigner(this.connection, "Ethereum node-owner commit") + } + + /** Validate the depositor key shape and its relationship to the signer. */ + private assertDepositorPublicKey( + value: BytesLike, + owner: string + ): Uint8Array { + const publicKey = getBytes(value) + if ( + publicKey.length !== UncompressedPublicKeyByteLength || + publicKey[0] !== UncompressedPublicKeyPrefix + ) { + throw new Error( + "depositorPublicKey must be a 65-byte uncompressed SEC1 key." + ) + } + if (getAddress(computeAddress(hexlify(publicKey))) !== owner) { + throw new Error("depositorPublicKey does not belong to the EVM signer.") + } + return publicKey + } + + /** Validate and canonicalize the non-empty Wire account name. */ + private canonicalAccountName(value: string): string { + if (value.length === 0) { + throw new Error("wireAccountName must not be empty.") + } + return value + } + + /** Convert an sdk-core public key into BAR's generated WireKey structure. */ + private wireKey(publicKey: PublicKey) { + const keyType = match(publicKey.type) + .with(KeyType.K1, () => NodeOwnerWireKeyType.K1) + .with(KeyType.R1, () => NodeOwnerWireKeyType.R1) + .with(KeyType.EM, () => NodeOwnerWireKeyType.EM) + .with(KeyType.ED, () => NodeOwnerWireKeyType.ED) + .otherwise(type => { + throw new Error(`${type} is not a node-owner authority key type.`) + }) + + return { keyType, key: publicKey.data.array } + } + + /** Normalize one emitted token id to BAR's supported node-owner tier. */ + private static nodeOwnerTier(value: BigNumberish): EthereumNodeOwnerTier { + return match(Number(value.toString())) + .with(EthereumNodeOwnerTier.T1, () => EthereumNodeOwnerTier.T1) + .with(EthereumNodeOwnerTier.T2, () => EthereumNodeOwnerTier.T2) + .with(EthereumNodeOwnerTier.T3, () => EthereumNodeOwnerTier.T3) + .otherwise(tokenId => { + throw new Error(`NodeCommitted emitted unsupported tier ${tokenId}.`) + }) + } +} diff --git a/packages/sdk-outpost/src/clients/ethereum/EthereumOutpostClient.ts b/packages/sdk-outpost/src/clients/ethereum/EthereumOutpostClient.ts index 7586c52..9c5ed80 100644 --- a/packages/sdk-outpost/src/clients/ethereum/EthereumOutpostClient.ts +++ b/packages/sdk-outpost/src/clients/ethereum/EthereumOutpostClient.ts @@ -1,4 +1,5 @@ import { + BAR__factory, OPPInbound__factory, OPP__factory, OperatorRegistry__factory, @@ -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 { @@ -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. */ @@ -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 @@ -64,6 +84,15 @@ export class EthereumOutpostClient { contract(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, diff --git a/packages/sdk-outpost/src/clients/ethereum/Types.ts b/packages/sdk-outpost/src/clients/ethereum/Types.ts index 2c29ef5..001d764 100644 --- a/packages/sdk-outpost/src/clients/ethereum/Types.ts +++ b/packages/sdk-outpost/src/clients/ethereum/Types.ts @@ -1,4 +1,5 @@ import type { + BAR, OPP, OPPInbound, OperatorRegistry, @@ -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. */ diff --git a/packages/sdk-outpost/src/clients/ethereum/index.ts b/packages/sdk-outpost/src/clients/ethereum/index.ts index c3df2d2..7f8f194 100644 --- a/packages/sdk-outpost/src/clients/ethereum/index.ts +++ b/packages/sdk-outpost/src/clients/ethereum/index.ts @@ -1,3 +1,4 @@ export * from "./EthereumReserveSwapClient.js" export * from "./EthereumReserveClient.js" +export * from "./EthereumNodeOwnerClient.js" export * from "./Types.js" diff --git a/packages/sdk-outpost/src/deployments/Schema.ts b/packages/sdk-outpost/src/deployments/Schema.ts index f7260db..9a50a12 100644 --- a/packages/sdk-outpost/src/deployments/Schema.ts +++ b/packages/sdk-outpost/src/deployments/Schema.ts @@ -59,6 +59,8 @@ export const OutpostDeploymentProfileSchema = z ethereum: z.object({ chainId: z.number().int().positive(), contracts: z.object({ + [EthereumContractName.BAR]: + EthereumContractDeploymentProfileSchema.optional(), [EthereumContractName.OPP]: EthereumContractDeploymentProfileSchema, [EthereumContractName.OPPInbound]: EthereumContractDeploymentProfileSchema, diff --git a/packages/sdk-outpost/src/deployments/Types.ts b/packages/sdk-outpost/src/deployments/Types.ts index fe6a4ef..33abb78 100644 --- a/packages/sdk-outpost/src/deployments/Types.ts +++ b/packages/sdk-outpost/src/deployments/Types.ts @@ -6,6 +6,7 @@ export enum OutpostChainFamily { /** Ethereum contracts owned by the current outpost deployment. */ export enum EthereumContractName { + BAR = "BAR", OPP = "OPP", OPPInbound = "OPPInbound", OperatorRegistry = "OperatorRegistry", diff --git a/packages/sdk-outpost/src/verification/OutpostDeploymentVerifier.ts b/packages/sdk-outpost/src/verification/OutpostDeploymentVerifier.ts index 43a7791..aefd2fb 100644 --- a/packages/sdk-outpost/src/verification/OutpostDeploymentVerifier.ts +++ b/packages/sdk-outpost/src/verification/OutpostDeploymentVerifier.ts @@ -77,8 +77,9 @@ async function verifyEthereum( await Promise.all( Object.values(EthereumContractName).map(async contractName => { - const contract = profile.ethereum.contracts[contractName], - proxyCode = await provider.getCode(contract.address) + const contract = profile.ethereum.contracts[contractName] + if (contract == null) return + const proxyCode = await provider.getCode(contract.address) if (proxyCode === EmptyEthereumCode) { throw new Error( diff --git a/packages/sdk-outpost/tests/Fixtures.ts b/packages/sdk-outpost/tests/Fixtures.ts index 94e78ab..9e593dc 100644 --- a/packages/sdk-outpost/tests/Fixtures.ts +++ b/packages/sdk-outpost/tests/Fixtures.ts @@ -159,7 +159,7 @@ export function createEthereumProviderFixture( ) jest.spyOn(provider, "getCode").mockImplementation(async address => { const implementation = Object.entries(profile.ethereum.contracts).find( - ([, deployment]) => deployment.implementationAddress === address + ([, deployment]) => deployment?.implementationAddress === address ) if (implementation != null) { return createEthereumImplementationCode( @@ -167,14 +167,14 @@ export function createEthereumProviderFixture( ) } return Object.values(profile.ethereum.contracts).some( - deployment => deployment.address === address + deployment => deployment?.address === address ) ? TestEthereumProxyCode : "0x" }) jest.spyOn(provider, "getStorage").mockImplementation(async address => { const contract = Object.values(profile.ethereum.contracts).find( - deployment => deployment.address === address + deployment => deployment?.address === address ) return zeroPadValue( contract?.implementationAddress ?? diff --git a/packages/sdk-outpost/tests/clients/ethereum/EthereumNodeOwnerClient.test.ts b/packages/sdk-outpost/tests/clients/ethereum/EthereumNodeOwnerClient.test.ts new file mode 100644 index 0000000..7fa806c --- /dev/null +++ b/packages/sdk-outpost/tests/clients/ethereum/EthereumNodeOwnerClient.test.ts @@ -0,0 +1,188 @@ +import type { BAR, IERC1155 } from "@wireio/outpost-ethereum-artifacts" +import { IERC1155__factory } from "@wireio/outpost-ethereum-artifacts" +import { KeyType, Name, PublicKey } from "@wireio/sdk-core" +import { + JsonRpcProvider, + Wallet, + ZeroAddress, + getBytes, + type EventLog, + type TransactionReceipt, + type TransactionResponse +} from "ethers" + +import { + EthereumNodeOwnerClient, + EthereumNodeOwnerTier +} from "@wireio/sdk-outpost" + +const BarAddress = "0x18E317A7D70d8fBf8e6E893616b52390EbBdb629", + TokenContractAddress = "0x5c74c94173F05dA1720953407cbb920F3DF9f887", + CommitTransactionHash = `0x${"11".repeat(32)}`, + ApprovalTransactionHash = `0x${"22".repeat(32)}`, + TestPrivateKey = `0x${"33".repeat(32)}`, + WireAccountName = "nodeowner", + WireAccount = Name.from(WireAccountName), + TransactionReceiptFixture = { + logs: [], + status: 1 + } as unknown as TransactionReceipt + +/** Create a confirmed transaction fixture with optional parsed logs. */ +function transactionFixture( + hash: string, + logs: readonly EventLog[] = [] +): TransactionResponse { + return { + hash, + wait: jest.fn(async () => ({ ...TransactionReceiptFixture, logs })) + } as unknown as TransactionResponse +} + +/** Create generated BAR and IERC-1155 fixtures for one node-owner flow. */ +function contractFixtures( + approved = true, + tokenContractAddress = TokenContractAddress +) { + const wallet = new Wallet(TestPrivateKey), + committedEvent = { + eventName: "NodeCommitted", + args: [ + wallet.address, + BigInt(EthereumNodeOwnerTier.T2), + TokenContractAddress, + WireAccountName + ] + } as unknown as EventLog, + commitTransaction = transactionFixture(CommitTransactionHash, [ + committedEvent + ]), + approvalTransaction = transactionFixture(ApprovalTransactionHash), + bar = { + target: BarAddress, + getAddress: jest.fn(async () => BarAddress), + wireNodesContract: jest.fn(async () => tokenContractAddress), + commitNode: jest.fn(async () => commitTransaction) + } as unknown as BAR, + token = { + balanceOf: jest.fn(async (_owner: string, tokenId: number) => + BigInt(tokenId === EthereumNodeOwnerTier.T2 ? 1 : 0) + ), + isApprovedForAll: jest.fn(async () => approved), + setApprovalForAll: jest.fn(async () => approvalTransaction) + } as unknown as IERC1155 + + jest.spyOn(IERC1155__factory, "connect").mockReturnValue(token) + return { wallet, bar, token, commitTransaction, approvalTransaction } +} + +/** Return an sdk-core public-key input aligned with the EVM test signer. */ +function wirePublicKey(wallet: Wallet): PublicKey { + return PublicKey.from({ + type: KeyType.K1, + compressed: getBytes(wallet.signingKey.compressedPublicKey) + }) +} + +afterEach(() => jest.restoreAllMocks()) + +describe("EthereumNodeOwnerClient", () => { + it("reads only owned tiers from BAR's canonical WireNodes contract", async () => { + const { wallet, bar, token } = contractFixtures(), + client = new EthereumNodeOwnerClient(bar, wallet) + + await expect(client.ownedSlots(wallet.address)).resolves.toEqual([ + { + tokenId: EthereumNodeOwnerTier.T2, + balance: 1n, + tokenContractAddress: TokenContractAddress + } + ]) + expect(token.balanceOf).toHaveBeenCalledTimes(3) + }) + + it("commits through BAR without an unnecessary approval", async () => { + const { wallet, bar, token, commitTransaction } = contractFixtures(), + client = new EthereumNodeOwnerClient(bar, wallet), + submission = await client.commit({ + tokenId: EthereumNodeOwnerTier.T2, + wireAccountName: WireAccount, + wirePublicKey: wirePublicKey(wallet), + depositorPublicKey: wallet.signingKey.publicKey + }) + + expect(submission).toEqual({ + transactionId: CommitTransactionHash, + approvalTransactionId: undefined, + committed: { + owner: wallet.address, + tokenId: EthereumNodeOwnerTier.T2, + tokenContractAddress: TokenContractAddress, + wireAccountName: WireAccountName + } + }) + expect(token.setApprovalForAll).not.toHaveBeenCalled() + expect(bar.commitNode).toHaveBeenCalledWith( + EthereumNodeOwnerTier.T2, + WireAccountName, + expect.objectContaining({ keyType: 1 }), + getBytes(wallet.signingKey.publicKey) + ) + expect(commitTransaction.wait).toHaveBeenCalledWith(1) + }) + + it("confirms ERC-1155 approval before committing when required", async () => { + const { wallet, bar, token, approvalTransaction } = contractFixtures(false), + client = new EthereumNodeOwnerClient(bar, wallet) + + await expect( + client.commit({ + tokenId: EthereumNodeOwnerTier.T2, + wireAccountName: WireAccount, + wirePublicKey: wirePublicKey(wallet), + depositorPublicKey: wallet.signingKey.publicKey + }) + ).resolves.toEqual( + expect.objectContaining({ + approvalTransactionId: ApprovalTransactionHash + }) + ) + expect(token.setApprovalForAll).toHaveBeenCalledWith(BarAddress, true) + expect(approvalTransaction.wait).toHaveBeenCalledWith(1) + }) + + it("rejects provider-only writes and depositor keys from another signer", async () => { + const { wallet, bar } = contractFixtures(), + providerClient = new EthereumNodeOwnerClient( + bar, + new JsonRpcProvider() + ), + signerClient = new EthereumNodeOwnerClient(bar, wallet), + otherWallet = Wallet.createRandom(), + request = { + tokenId: EthereumNodeOwnerTier.T2, + wireAccountName: WireAccount, + wirePublicKey: wirePublicKey(wallet), + depositorPublicKey: wallet.signingKey.publicKey + } + + await expect(providerClient.commit(request)).rejects.toThrow( + "requires a connected signer" + ) + await expect( + signerClient.commit({ + ...request, + depositorPublicKey: otherWallet.signingKey.publicKey + }) + ).rejects.toThrow("does not belong to the EVM signer") + expect(bar.commitNode).not.toHaveBeenCalled() + }) + + it("fails closed when BAR has no canonical WireNodes contract", async () => { + const { wallet, bar } = contractFixtures(true, ZeroAddress) + + await expect( + new EthereumNodeOwnerClient(bar, wallet).canonicalTokenContractAddress() + ).rejects.toThrow("no canonical WireNodes contract") + }) +}) diff --git a/packages/sdk-outpost/tests/clients/ethereum/EthereumOutpostClient.test.ts b/packages/sdk-outpost/tests/clients/ethereum/EthereumOutpostClient.test.ts index 74d027d..7ee3a83 100644 --- a/packages/sdk-outpost/tests/clients/ethereum/EthereumOutpostClient.test.ts +++ b/packages/sdk-outpost/tests/clients/ethereum/EthereumOutpostClient.test.ts @@ -123,6 +123,23 @@ describe("EthereumOutpostClient", () => { ) }) + it("keeps existing Ethereum clients usable when BAR is not deployed", async () => { + const profile = createOutpostDeploymentProfileFixture() + delete profile.ethereum.contracts[EthereumContractName.BAR] + const provider = createEthereumProviderFixture(profile), + client = await createEthereumClient({ + profile, + connection: provider + }) + + expect(client.reserves).toBeInstanceOf(EthereumReserveClient) + expect(client.swaps).toBeInstanceOf(EthereumReserveSwapClient) + expect(() => client.nodeOwners).toThrow("has no BAR identity") + expect(provider.getCode).toHaveBeenCalledTimes( + (Object.values(EthereumContractName).length - 1) * 2 + ) + }) + it("parses the protocol deposit id from a confirmed receipt", () => { const events = [ { eventName: "SwapDeposit", args: [42n] } as unknown as EventLog diff --git a/packages/sdk-outpost/tests/deployments/Schema.test.ts b/packages/sdk-outpost/tests/deployments/Schema.test.ts index 6148060..868ea69 100644 --- a/packages/sdk-outpost/tests/deployments/Schema.test.ts +++ b/packages/sdk-outpost/tests/deployments/Schema.test.ts @@ -31,6 +31,17 @@ describe("OutpostDeploymentProfileSchema", () => { ) }) + it("preserves schema-v1 profiles when BAR is not deployed", () => { + const profile = createOutpostDeploymentProfileFixture() + delete profile.ethereum.contracts[EthereumContractName.BAR] + + expect( + parseOutpostDeploymentProfile(profile).ethereum.contracts[ + EthereumContractName.BAR + ] + ).toBeUndefined() + }) + it("rejects an invalid Solana ProgramData address", () => { const fixture = createOutpostDeploymentProfileFixture() fixture.solana.programs.liqsolCore.programDataAddress = diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bec2e79..421e7c3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -202,6 +202,9 @@ importers: '@wireio/outpost-solana-artifacts': specifier: 0.2.1 version: 0.2.1 + '@wireio/sdk-core': + specifier: workspace:* + version: link:../sdk-core ethers: specifier: ^6.15.0 version: 6.17.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)