From 37a164f1bb45b205bd3113e7ee919a97085291a2 Mon Sep 17 00:00:00 2001 From: joshglogau Date: Fri, 7 Aug 2026 13:20:51 -0400 Subject: [PATCH 1/3] feat(sdk-core): match reserve pairs atomically --- packages/sdk-core/README.md | 15 +++++++ packages/sdk-core/package.json | 5 +++ .../src/contracts/sysio/reserv/Client.ts | 22 ++++++++- .../src/contracts/sysio/reserv/Types.ts | 8 ++++ .../contracts/sysio/reserv/Client.test.ts | 45 +++++++++++++++++++ 5 files changed, 94 insertions(+), 1 deletion(-) diff --git a/packages/sdk-core/README.md b/packages/sdk-core/README.md index 483042f..1016b50 100644 --- a/packages/sdk-core/README.md +++ b/packages/sdk-core/README.md @@ -122,8 +122,23 @@ await reserves.pushMatchReserve({ matcher: "alice", wireAmount: pending[0].requestedWireAmount }) + +// Several pending rows can be activated atomically in one signed transaction. +await reserves.pushMatchReserves({ + matches: pending.map(reserve => ({ + chainCode: reserve.chainCode, + tokenCode: reserve.tokenCode, + reserveCode: reserve.reserveCode, + matcher: "alice", + wireAmount: reserve.requestedWireAmount + })) +}) ``` +`pushMatchReserves` preserves the supplied action order and rejects an empty +match list. The Wire transaction is atomic: either every `matchreserve` action +is accepted or none is applied. + ## Reserve swaps Reserve swap integrations compose three on-chain sources instead of carrying a diff --git a/packages/sdk-core/package.json b/packages/sdk-core/package.json index 14c8062..92a048a 100644 --- a/packages/sdk-core/package.json +++ b/packages/sdk-core/package.json @@ -30,6 +30,11 @@ "types": "./lib/esm/*.d.ts" } }, + "typesVersions": { + "*": { + "*": ["lib/esm/*"] + } + }, "access": "public", "license": "FSL-1.1-Apache-2.0", "scripts": { diff --git a/packages/sdk-core/src/contracts/sysio/reserv/Client.ts b/packages/sdk-core/src/contracts/sysio/reserv/Client.ts index 8f000b9..2d13a8b 100644 --- a/packages/sdk-core/src/contracts/sysio/reserv/Client.ts +++ b/packages/sdk-core/src/contracts/sysio/reserv/Client.ts @@ -14,7 +14,11 @@ import type * as SysioContracts from "../../../types/SysioContractTypes.js" import type { ContractTableRowsOptions } from "../../Contract.js" import { getSysioContract, type SysioContractClient } from "../Client.js" -import { buildSwapQuoteAction, matchReserveActionData } from "./Actions.js" +import { + buildMatchReserveAction, + buildSwapQuoteAction, + matchReserveActionData +} from "./Actions.js" import { DEFAULT_RESERV_CONTRACT, DEFAULT_RESERVE_QUERY_LIMIT @@ -27,6 +31,7 @@ import { import type { ListReservesOptions, PushMatchReserveOptions, + PushMatchReservesOptions, ReserveClientOptions, ReserveIdentity, ReserveQuoteOptions, @@ -271,6 +276,21 @@ export class ReserveClient { ) } + /** Builds and pushes one signed Wire transaction that activates several pending reserves atomically. */ + async pushMatchReserves( + options: PushMatchReservesOptions, + pushOptions: TransactionExtraOptions = options.pushOptions || {} + ): Promise>> { + if (options.matches.length === 0) { + throw new Error("At least one reserve match is required.") + } + + return this.client.pushTransaction( + options.matches.map(buildMatchReserveAction), + pushOptions + ) + } + /** Reads the current on-chain quote for one reserve route. */ async getSwapQuote(options: ReserveQuoteOptions): Promise { const response = await this.sendReadOnlyAction( diff --git a/packages/sdk-core/src/contracts/sysio/reserv/Types.ts b/packages/sdk-core/src/contracts/sysio/reserv/Types.ts index cf0a434..ed5b265 100644 --- a/packages/sdk-core/src/contracts/sysio/reserv/Types.ts +++ b/packages/sdk-core/src/contracts/sysio/reserv/Types.ts @@ -59,6 +59,14 @@ export interface PushMatchReserveOptions extends MatchReserveOptions { pushOptions?: TransactionExtraOptions } +/** Options for atomically matching several pending reserves on Wire. */ +export interface PushMatchReservesOptions { + /** Ordered reserve matches included in one Wire transaction. */ + matches: readonly MatchReserveOptions[] + /** Optional push behavior such as finality waiting. */ + pushOptions?: TransactionExtraOptions +} + /** Options for a read-only reserve-to-reserve quote. */ export interface ReserveQuoteOptions { /** Source reserve identity. */ diff --git a/packages/sdk-core/tests/contracts/sysio/reserv/Client.test.ts b/packages/sdk-core/tests/contracts/sysio/reserv/Client.test.ts index 359a1c2..a52a606 100644 --- a/packages/sdk-core/tests/contracts/sysio/reserv/Client.test.ts +++ b/packages/sdk-core/tests/contracts/sysio/reserv/Client.test.ts @@ -163,6 +163,51 @@ describe("ReserveClient", () => { expect(action.authorization.map(String)).toEqual(["alice@active"]) }) + test("pushes ordered reserve matches in one Wire transaction", async () => { + const { client, pushTransaction } = clientFixture() + + await expect( + client.pushMatchReserves({ + matches: [ + { + chainCode: "ETHEREUM", + tokenCode: "ETH", + reserveCode: "PRIVATE", + matcher: "alice", + wireAmount: "2500000000" + }, + { + chainCode: "SOLANA", + tokenCode: "USDC", + reserveCode: "PRIVATE", + matcher: "alice", + wireAmount: "2500000000" + } + ] + }) + ).resolves.toEqual({ transaction_id: "reserve-trx" }) + + const [actions] = pushTransaction.mock.calls[0] + expect(actions).toHaveLength(2) + expect(actions.map(action => action.name.toString())).toEqual([ + "matchreserve", + "matchreserve" + ]) + expect(actions.map(action => action.authorization.map(String))).toEqual([ + ["alice@active"], + ["alice@active"] + ]) + }) + + test("rejects an empty atomic reserve match", async () => { + const { client, pushTransaction } = clientFixture() + + await expect(client.pushMatchReserves({ matches: [] })).rejects.toThrow( + "At least one reserve match is required." + ) + expect(pushTransaction).not.toHaveBeenCalled() + }) + test("decodes read-only swapquote and rewards values", async () => { const { client } = clientFixture() From 6d8654674d1ee28da3ec46454f732528013d00db Mon Sep 17 00:00:00 2001 From: joshglogau Date: Fri, 7 Aug 2026 13:21:02 -0400 Subject: [PATCH 2/3] feat(sdk-outpost): add private reserve lifecycle --- packages/sdk-outpost/README.md | 59 +++- packages/sdk-outpost/package.json | 1 + .../clients/ethereum/EthereumOutpostClient.ts | 8 + .../clients/ethereum/EthereumReserveClient.ts | 208 ++++++++++++++ .../sdk-outpost/src/clients/ethereum/index.ts | 1 + .../src/clients/solana/SolanaOutpostClient.ts | 5 + .../clients/solana/SolanaReserveAddresses.ts | 75 ++++++ .../src/clients/solana/SolanaReserveClient.ts | 255 ++++++++++++++++++ .../clients/solana/SolanaReserveSwapClient.ts | 72 ++--- .../sdk-outpost/src/clients/solana/index.ts | 2 + packages/sdk-outpost/src/reserves/Types.ts | 117 ++++++++ .../sdk-outpost/src/reserves/Validation.ts | 79 +++++- .../ethereum/EthereumOutpostClient.test.ts | 2 + .../ethereum/EthereumReserveClient.test.ts | 202 ++++++++++++++ .../solana/SolanaOutpostClient.test.ts | 2 + .../solana/SolanaReserveAddresses.test.ts | 66 +++++ .../solana/SolanaReserveClient.test.ts | 185 +++++++++++++ .../tests/reserves/Validation.test.ts | 45 ++++ pnpm-lock.yaml | 3 + scripts/sdk-outpost/verify-package.mjs | 2 + 20 files changed, 1336 insertions(+), 53 deletions(-) create mode 100644 packages/sdk-outpost/src/clients/ethereum/EthereumReserveClient.ts create mode 100644 packages/sdk-outpost/src/clients/solana/SolanaReserveAddresses.ts create mode 100644 packages/sdk-outpost/src/clients/solana/SolanaReserveClient.ts create mode 100644 packages/sdk-outpost/tests/clients/ethereum/EthereumReserveClient.test.ts create mode 100644 packages/sdk-outpost/tests/clients/solana/SolanaReserveAddresses.test.ts create mode 100644 packages/sdk-outpost/tests/clients/solana/SolanaReserveClient.test.ts diff --git a/packages/sdk-outpost/README.md b/packages/sdk-outpost/README.md index 404b836..005fc1f 100644 --- a/packages/sdk-outpost/README.md +++ b/packages/sdk-outpost/README.md @@ -22,9 +22,9 @@ module entrypoints with TypeScript declarations. ## Supported surfaces | Family | Generated clients and workflows | -| -------- | ------------------------------------------------------------------------ | -| Ethereum | `OPP`, `OPPInbound`, `OperatorRegistry`, `ReserveManager`, reserve swaps | -| Solana | `liqsol_core`, native SOL and classic SPL reserve swaps | +| -------- | ------------------------------------------------------------------------- | +| Ethereum | `OPP`, `OPPInbound`, `OperatorRegistry`, `ReserveManager`, reserve lifecycle and swaps | +| Solana | `liqsol_core`, configured reserve lifecycle, native SOL and classic SPL reserve swaps | Client creation verifies all four boundaries before returning: @@ -120,6 +120,56 @@ Ethereum also exposes `requestErc20WithApproval`, `nativeBalance`, and `erc20Balance`. Solana exposes `requestNative`, `requestSpl`, `nativeBalance`, and `splBalance` through the same `client.swaps` ownership boundary. +## Reserve lifecycle + +Wallet-connected clients expose the external half of the post-bootstrap +reserve lifecycle. The external create escrows reserve capital and emits the +attestation that creates a pending `sysio.reserv` row. A signed +`@wireio/sdk-core` `ReserveClient` then supplies the exact requested WIRE amount +and activates that row. + +```ts +const ethereumSubmission = await ethereum.reserves.createNative({ + tokenCode, + reserveCode, + externalTokenAmount, + requestedWireAmount, + connectorWeightBps: 5_000, + name: "Private ETH reserve", + description: "", + isPrivate: true, + creatorPubKey +}) + +const configuredTokens = await solana.reserves.getConfiguredTokens() +const splToken = configuredTokens.find(token => !token.isNative) +if (splToken == null) throw new Error("No configured SPL reserve token.") + +const solanaSubmission = await solana.reserves.create({ + tokenCode: splToken.tokenCode, + reserveCode, + externalTokenAmount: splAmount, + requestedWireAmount, + connectorWeightBps: 5_000, + name: "Private SPL reserve", + description: "", + isPrivate: true, + mint: splToken.mint +}) +``` + +Ethereum supports native creation, ERC-20 approval or permit creation, pending +cancellation, and local reserve reads. Solana supports deployment-configured +token discovery, instruction assembly, creation, pending cancellation, address +derivation, and local reserve reads. `cancel` is valid only while creation is +pending and drives the protocol refund path. + +Private is a routing constraint, not access control or confidentiality. Private +reserves cannot use WIRE as a swap endpoint; when either external route leg is +private, Wire requires both active reserves to have the same non-empty owner. +The current protocol exposes no creator withdrawal, close, or redemption after +activation. This SDK intentionally does not invent an active-reserve exit API. + Solana uses the same facade and returns the precise Anchor program type at the runtime program address: @@ -155,7 +205,8 @@ repository. ## Consumer boundaries - Use this package for typed external `ReserveManager`, `OperatorRegistry`, - `OPP`, `OPPInbound`, `liqsol_core`, and source reserve-swap execution. + `OPP`, `OPPInbound`, `liqsol_core`, reserve lifecycle, and source reserve-swap + execution. - Use `@wireio/sdk-core` for Wire transaction construction, reserve and token registries, underwriting state, and settlement correlation. - Recreate external clients whenever the selected deployment profile changes. diff --git a/packages/sdk-outpost/package.json b/packages/sdk-outpost/package.json index 45075f6..8b190a9 100644 --- a/packages/sdk-outpost/package.json +++ b/packages/sdk-outpost/package.json @@ -52,6 +52,7 @@ "prepack": "pnpm run verify:release" }, "dependencies": { + "@wireio/sdk-core": "workspace:*", "@coral-xyz/anchor": "^0.32.1", "@ethersproject/abi": "^5.8.0", "@ethersproject/providers": "^5.8.0", diff --git a/packages/sdk-outpost/src/clients/ethereum/EthereumOutpostClient.ts b/packages/sdk-outpost/src/clients/ethereum/EthereumOutpostClient.ts index 2f5aa0d..59c6652 100644 --- a/packages/sdk-outpost/src/clients/ethereum/EthereumOutpostClient.ts +++ b/packages/sdk-outpost/src/clients/ethereum/EthereumOutpostClient.ts @@ -13,6 +13,7 @@ import { } from "../../deployments/index.js" import { OutpostDeploymentVerifier } from "../../verification/index.js" import { EthereumContractMap, EthereumOutpostClientOptions } from "./Types.js" +import { EthereumReserveClient } from "./EthereumReserveClient.js" import { EthereumReserveSwapClient } from "./EthereumReserveSwapClient.js" function resolveProvider( @@ -47,12 +48,19 @@ export class EthereumOutpostClient { /** Provider verified against the configured Ethereum chain. */ readonly provider: providers.Provider ) { + this.reserves = new EthereumReserveClient( + this.contract(EthereumContractName.ReserveManager), + options.connection + ) this.swaps = new EthereumReserveSwapClient( this.contract(EthereumContractName.ReserveManager), options.connection ) } + /** Reserve creation, cancellation, and reads for this verified outpost. */ + readonly reserves: EthereumReserveClient + /** Reserve-swap writes and balance reads for this verified outpost. */ readonly swaps: EthereumReserveSwapClient diff --git a/packages/sdk-outpost/src/clients/ethereum/EthereumReserveClient.ts b/packages/sdk-outpost/src/clients/ethereum/EthereumReserveClient.ts new file mode 100644 index 0000000..cbec80b --- /dev/null +++ b/packages/sdk-outpost/src/clients/ethereum/EthereumReserveClient.ts @@ -0,0 +1,208 @@ +import { + constants as ethersConstants, + Contract, + Signer, + type providers +} from "ethers" +import { match } from "ts-pattern" + +import type { ReserveManager } from "../../contracts/ethereum/index.js" +import type { ReserveManagerLib } from "../../contracts/ethereum/generated/ReserveManager.js" +import { + assertEthereumReserveCreateRequest, + assertReserveUnsigned64, + OutpostReserveStatus, + type EthereumReserveCreateRequest, + type EthereumReservePermitSignature, + type EthereumReserveRecord, + type OutpostReserveIdentity, + type OutpostReserveSubmission +} from "../../reserves/index.js" + +const ConfirmationCount = 1, + EthereumLocalReserveStatus = { + pending: 0, + active: 1, + cancelled: 2 + } as const, + Erc20Interface = [ + "function allowance(address owner,address spender) view returns (uint256)", + "function approve(address spender,uint256 amount) returns (bool)" + ] + +/** Reserve creation, cancellation, and reads for one verified Ethereum outpost. */ +export class EthereumReserveClient { + /** Bind reserve lifecycle operations to a generated ReserveManager client. */ + constructor( + private readonly reserveManager: ReserveManager, + private readonly connection: providers.Provider | Signer + ) {} + + /** Create a pending native-token reserve. */ + async createNative( + request: EthereumReserveCreateRequest + ): Promise { + assertEthereumReserveCreateRequest(request) + this.assertSigner() + const parameters = this.nativeParameters(request), + overrides = { value: request.externalTokenAmount } + + await this.reserveManager.callStatic.create_reserve( + ...parameters, + overrides + ) + const transaction = await this.reserveManager.create_reserve( + ...parameters, + overrides + ) + await transaction.wait(ConfirmationCount) + return { transactionId: transaction.hash } + } + + /** Approve and create a pending ERC-20 reserve. */ + async createErc20WithApproval( + request: EthereumReserveCreateRequest, + tokenAddress?: string + ): Promise { + assertEthereumReserveCreateRequest(request) + const signer = this.assertSigner(), + owner = await signer.getAddress(), + resolvedTokenAddress = + tokenAddress ?? + (await this.reserveManager.tokenAddressesByCode(request.tokenCode)) + + if (resolvedTokenAddress === ethersConstants.AddressZero) { + throw new Error( + `No ERC-20 address is configured for tokenCode ${request.tokenCode.toString()}.` + ) + } + + const token = new Contract(resolvedTokenAddress, Erc20Interface, signer), + allowance = await token.allowance(owner, this.reserveManager.address) + if (allowance.lt(request.externalTokenAmount)) { + const approval = await token.approve( + this.reserveManager.address, + request.externalTokenAmount + ) + await approval.wait(ConfirmationCount) + } + + const arguments_ = this.createArguments(request) + await this.reserveManager.callStatic.requestReserveCreateErc20WithApproval( + arguments_ + ) + const transaction = + await this.reserveManager.requestReserveCreateErc20WithApproval(arguments_) + await transaction.wait(ConfirmationCount) + return { transactionId: transaction.hash } + } + + /** Create a pending ERC-20 reserve using an EIP-2612 permit. */ + async createErc20WithPermit( + request: EthereumReserveCreateRequest, + permitSignature: EthereumReservePermitSignature + ): Promise { + assertEthereumReserveCreateRequest(request) + this.assertSigner() + const arguments_ = this.createArguments(request) + + await this.reserveManager.callStatic.requestReserveCreateErc20WithPermit( + arguments_, + permitSignature + ) + const transaction = + await this.reserveManager.requestReserveCreateErc20WithPermit( + arguments_, + permitSignature + ) + await transaction.wait(ConfirmationCount) + return { transactionId: transaction.hash } + } + + /** Request cancellation and refund of a pending reserve. */ + async cancel( + identity: OutpostReserveIdentity + ): Promise { + this.assertSigner() + assertReserveUnsigned64(identity.tokenCode, "tokenCode") + assertReserveUnsigned64(identity.reserveCode, "reserveCode") + const transaction = await this.reserveManager.cancel_create_reserve( + identity.tokenCode, + identity.reserveCode + ) + await transaction.wait(ConfirmationCount) + return { transactionId: transaction.hash } + } + + /** Read and normalize one local ReserveManager record. */ + async get(identity: OutpostReserveIdentity): Promise { + assertReserveUnsigned64(identity.tokenCode, "tokenCode") + assertReserveUnsigned64(identity.reserveCode, "reserveCode") + const reserve = await this.reserveManager.getReserve( + identity.tokenCode, + identity.reserveCode + ) + return { + tokenCode: reserve.tokenCode.toBigInt(), + reserveCode: reserve.reserveCode.toBigInt(), + externalTokenAmount: reserve.externalTokenAmount.toBigInt(), + requestedWireAmount: reserve.requestedWireAmount.toBigInt(), + connectorWeightBps: reserve.connectorWeightBps, + status: match(reserve.status) + .with( + EthereumLocalReserveStatus.pending, + () => OutpostReserveStatus.pending + ) + .with( + EthereumLocalReserveStatus.active, + () => OutpostReserveStatus.active + ) + .with( + EthereumLocalReserveStatus.cancelled, + () => OutpostReserveStatus.cancelled + ) + .otherwise(value => { + throw new Error(`Unsupported Ethereum reserve status ${value}.`) + }), + creator: reserve.creator, + exists: reserve.exists + } + } + + private assertSigner(): Signer { + if (!Signer.isSigner(this.connection)) { + throw new Error("Ethereum reserve operation requires a connected signer.") + } + return this.connection + } + + private createArguments( + request: EthereumReserveCreateRequest + ): ReserveManagerLib.ReserveCreateArgsStruct { + return { + tokenCode: request.tokenCode, + reserveCode: request.reserveCode, + externalTokenAmount: request.externalTokenAmount, + requestedWireAmount: request.requestedWireAmount, + connectorWeightBps: request.connectorWeightBps, + name: request.name, + description: request.description, + isPrivate: request.isPrivate, + creatorPubKey: request.creatorPubKey + } + } + + private nativeParameters(request: EthereumReserveCreateRequest) { + return [ + request.tokenCode, + request.reserveCode, + request.externalTokenAmount, + request.requestedWireAmount, + request.connectorWeightBps, + request.name, + request.description, + request.isPrivate, + request.creatorPubKey + ] as const + } +} diff --git a/packages/sdk-outpost/src/clients/ethereum/index.ts b/packages/sdk-outpost/src/clients/ethereum/index.ts index b828c96..7b4caa6 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 "./EthereumOutpostClient.js" export * from "./Types.js" diff --git a/packages/sdk-outpost/src/clients/solana/SolanaOutpostClient.ts b/packages/sdk-outpost/src/clients/solana/SolanaOutpostClient.ts index 2d27d7f..010dc23 100644 --- a/packages/sdk-outpost/src/clients/solana/SolanaOutpostClient.ts +++ b/packages/sdk-outpost/src/clients/solana/SolanaOutpostClient.ts @@ -8,6 +8,7 @@ import { import { LiqsolCore, liqsolCoreIdl } from "../../programs/solana/index.js" import { OutpostDeploymentVerifier } from "../../verification/index.js" import { SolanaOutpostClientOptions, SolanaProgramMap } from "./Types.js" +import { SolanaReserveClient } from "./SolanaReserveClient.js" import { SolanaReserveSwapClient } from "./SolanaReserveSwapClient.js" /** Strictly typed access to one verified Solana outpost deployment. */ @@ -36,9 +37,13 @@ export class SolanaOutpostClient { { ...liqsolCoreIdl, address }, options.provider ) + this.reserves = new SolanaReserveClient(options.provider, this.liqsolCore) this.swaps = new SolanaReserveSwapClient(options.provider, this.liqsolCore) } + /** Reserve creation, cancellation, and reads for this verified outpost. */ + readonly reserves: SolanaReserveClient + /** Reserve-swap writes and balance reads for this verified outpost. */ readonly swaps: SolanaReserveSwapClient diff --git a/packages/sdk-outpost/src/clients/solana/SolanaReserveAddresses.ts b/packages/sdk-outpost/src/clients/solana/SolanaReserveAddresses.ts new file mode 100644 index 0000000..464e2ad --- /dev/null +++ b/packages/sdk-outpost/src/clients/solana/SolanaReserveAddresses.ts @@ -0,0 +1,75 @@ +import { BN } from "@coral-xyz/anchor" +import { PublicKey } from "@solana/web3.js" + +import { + assertReserveUnsigned64, + type OutpostReserveIdentity +} from "../../reserves/index.js" + +const SolanaReserveSeed = { + outpostConfig: Buffer.from("outpost_config"), + outboundMessageBuffer: Buffer.from("outbound_message_buffer"), + reserve: Buffer.from("reserve"), + reserveVault: Buffer.from("reserve_vault") + } as const, + Unsigned64ByteLength = 8 + +/** Canonical PDA derivation for Solana reserve lifecycle and swap clients. */ +export class SolanaReserveAddresses { + /** Bind reserve address derivation to one verified program deployment. */ + constructor(private readonly programId: PublicKey) {} + + /** Derive the singleton outpost configuration PDA. */ + outpostConfig(): PublicKey { + return this.derive([SolanaReserveSeed.outpostConfig]) + } + + /** Derive the singleton outbound message-buffer PDA. */ + outboundMessageBuffer(): PublicKey { + return this.derive([SolanaReserveSeed.outboundMessageBuffer]) + } + + /** Derive a reserve account PDA. */ + reserve(identity: OutpostReserveIdentity): PublicKey { + return this.reserveAddress(SolanaReserveSeed.reserve, identity) + } + + /** Derive a reserve custody-vault PDA. */ + reserveVault(identity: OutpostReserveIdentity): PublicKey { + return this.reserveAddress(SolanaReserveSeed.reserveVault, identity) + } + + /** Convert an SDK reserve integer to Anchor's u64 representation. */ + unsigned64( + value: OutpostReserveIdentity["tokenCode"], + field: string + ): BN { + return new BN(assertReserveUnsigned64(value, field).toString()) + } + + private derive(seeds: Buffer[]): PublicKey { + return PublicKey.findProgramAddressSync(seeds, this.programId)[0] + } + + private reserveAddress( + seed: Buffer, + identity: OutpostReserveIdentity + ): PublicKey { + return this.derive([ + seed, + this.unsigned64Seed(identity.tokenCode, "tokenCode"), + this.unsigned64Seed(identity.reserveCode, "reserveCode") + ]) + } + + private unsigned64Seed( + value: OutpostReserveIdentity["tokenCode"], + field: string + ): Buffer { + return this.unsigned64(value, field).toArrayLike( + Buffer, + "le", + Unsigned64ByteLength + ) + } +} diff --git a/packages/sdk-outpost/src/clients/solana/SolanaReserveClient.ts b/packages/sdk-outpost/src/clients/solana/SolanaReserveClient.ts new file mode 100644 index 0000000..d8e9fa9 --- /dev/null +++ b/packages/sdk-outpost/src/clients/solana/SolanaReserveClient.ts @@ -0,0 +1,255 @@ +import { + type AnchorProvider, + type IdlAccounts, + type Program +} from "@coral-xyz/anchor" +import { + createAssociatedTokenAccountIdempotentInstruction, + getAssociatedTokenAddressSync, + TOKEN_PROGRAM_ID +} from "@solana/spl-token" +import { + PublicKey, + SYSVAR_RENT_PUBKEY, + SystemProgram, + Transaction, + type TransactionInstruction +} from "@solana/web3.js" +import { match } from "ts-pattern" + +import type { LiqsolCore } from "../../programs/solana/index.js" +import { + assertReserveCreateDefinition, + assertReserveUnsigned64, + OutpostReserveStatus, + type OutpostReserveIdentity, + type OutpostReserveSubmission, + type SolanaConfiguredReserveToken, + type SolanaReserveCreateRequest, + type SolanaReserveRecord +} from "../../reserves/index.js" +import { SolanaReserveAddresses } from "./SolanaReserveAddresses.js" + +const PublicKeyByteLength = 32, + NativeSolanaDecimals = 9 + +type LiqsolCoreAccounts = IdlAccounts + +/** Reserve creation, cancellation, and reads for one verified Solana outpost. */ +export class SolanaReserveClient { + private readonly addresses: SolanaReserveAddresses + + /** Bind reserve lifecycle operations to a generated Anchor program client. */ + constructor( + private readonly provider: AnchorProvider, + private readonly program: Program + ) { + this.addresses = new SolanaReserveAddresses(program.programId) + } + + /** List token routes configured by the deployed outpost. */ + async getConfiguredTokens(): Promise { + const account = await this.provider.connection.getAccountInfo( + this.addresses.outpostConfig() + ) + if (account == null) { + throw new Error("Solana outpost configuration is unavailable.") + } + const config = this.program.coder.accounts.decode< + LiqsolCoreAccounts["outpostConfig"] + >("outpostConfig", account.data), + precisionByTokenCode = new Map( + config.precisionByTokenCode.map(entry => [ + entry.tokenCode.toString(), + entry.decimals + ]) + ), + nativeTokenMarker = new PublicKey( + new Uint8Array(PublicKeyByteLength) + ) + + return config.tokenAddressesByCode.map(entry => { + const tokenCode = entry.tokenCode.toString(), + isNative = entry.mint.equals(nativeTokenMarker), + configuredDecimals = precisionByTokenCode.get(tokenCode) + if (!isNative && configuredDecimals == null) { + throw new Error( + `Solana reserve token ${tokenCode} has no configured precision.` + ) + } + return { + tokenCode: BigInt(tokenCode), + mint: entry.mint, + isNative, + decimals: isNative ? NativeSolanaDecimals : configuredDecimals + } + }) + } + + /** Derive the local outpost account for one reserve identity. */ + deriveAddress(identity: OutpostReserveIdentity): PublicKey { + return this.addresses.reserve(identity) + } + + /** Build reserve-creation instructions without signing or submitting. */ + async createInstructions( + request: SolanaReserveCreateRequest + ): Promise { + assertReserveCreateDefinition(request) + assertReserveUnsigned64(request.externalTokenAmount, "externalTokenAmount") + const creator = this.assertWallet(), + { + creatorTokenAccount = getAssociatedTokenAddressSync( + request.mint, + creator, + false, + TOKEN_PROGRAM_ID + ) + } = request, + createReserve = await this.program.methods + .createReserve( + this.addresses.unsigned64(request.tokenCode, "tokenCode"), + this.addresses.unsigned64(request.reserveCode, "reserveCode"), + this.addresses.unsigned64( + request.externalTokenAmount, + "externalTokenAmount" + ), + this.addresses.unsigned64( + request.requestedWireAmount, + "requestedWireAmount" + ), + request.connectorWeightBps, + request.name, + request.description, + request.isPrivate + ) + .accounts({ + creator, + config: this.addresses.outpostConfig(), + reserve: this.addresses.reserve(request), + reserveVault: this.addresses.reserveVault(request), + mint: request.mint, + creatorAta: creatorTokenAccount, + outboundMessageBuffer: this.addresses.outboundMessageBuffer(), + tokenProgram: TOKEN_PROGRAM_ID, + systemProgram: SystemProgram.programId, + rent: SYSVAR_RENT_PUBKEY + }) + .instruction() + + if ( + request.ensureCreatorTokenAccount === false || + request.creatorTokenAccount != null + ) { + return [createReserve] + } + + return [ + createAssociatedTokenAccountIdempotentInstruction( + creator, + creatorTokenAccount, + creator, + request.mint, + TOKEN_PROGRAM_ID + ), + createReserve + ] + } + + /** Create a pending external reserve. */ + async create( + request: SolanaReserveCreateRequest + ): Promise { + const transactionId = await this.provider.sendAndConfirm( + new Transaction().add(...(await this.createInstructions(request))) + ) + return { transactionId } + } + + /** Build a creator-authorized pending-reserve cancellation instruction. */ + async cancelInstruction( + identity: OutpostReserveIdentity + ): Promise { + const creator = this.assertWallet() + return this.program.methods + .cancelCreateReserve( + this.addresses.unsigned64(identity.tokenCode, "tokenCode"), + this.addresses.unsigned64(identity.reserveCode, "reserveCode") + ) + .accounts({ + creator, + config: this.addresses.outpostConfig(), + reserve: this.addresses.reserve(identity), + outboundMessageBuffer: this.addresses.outboundMessageBuffer() + }) + .instruction() + } + + /** Request cancellation and refund of a pending reserve. */ + async cancel( + identity: OutpostReserveIdentity + ): Promise { + const transactionId = await this.provider.sendAndConfirm( + new Transaction().add(await this.cancelInstruction(identity)) + ) + return { transactionId } + } + + /** Read and normalize one local reserve account. */ + async get( + identity: OutpostReserveIdentity + ): Promise { + const address = this.addresses.reserve(identity), + account = await this.provider.connection.getAccountInfo(address) + if (account == null) return null + const reserve = this.program.coder.accounts.decode< + LiqsolCoreAccounts["reserve"] + >("reserve", account.data) + + return { + address, + vaultAddress: this.addresses.reserveVault(identity), + tokenCode: BigInt(reserve.tokenCode.toString()), + reserveCode: BigInt(reserve.reserveCode.toString()), + externalTokenAmount: BigInt(reserve.externalTokenAmount.toString()), + requestedWireAmount: BigInt(reserve.requestedWireAmount.toString()), + connectorWeightBps: reserve.connectorWeightBps, + status: match(reserve.status) + .when( + status => "pending" in status, + () => OutpostReserveStatus.pending + ) + .when( + status => "active" in status, + () => OutpostReserveStatus.active + ) + .when( + status => "cancelled" in status, + () => OutpostReserveStatus.cancelled + ) + .otherwise(() => { + throw new Error("Unsupported Solana reserve status.") + }), + creator: reserve.creator, + name: this.decodeFixedUtf8(reserve.nameBytes, reserve.nameLen), + description: this.decodeFixedUtf8( + reserve.descriptionBytes, + reserve.descriptionLen + ) + } + } + + private assertWallet(): PublicKey { + const publicKey = this.provider.wallet.publicKey + if (publicKey == null) { + throw new Error("Solana reserve operation requires a connected wallet.") + } + return publicKey + } + + private decodeFixedUtf8(value: ArrayLike, length: number): string { + return new TextDecoder().decode( + Uint8Array.from(value).slice(0, Number(length)) + ) + } +} diff --git a/packages/sdk-outpost/src/clients/solana/SolanaReserveSwapClient.ts b/packages/sdk-outpost/src/clients/solana/SolanaReserveSwapClient.ts index 6a66b31..537a65d 100644 --- a/packages/sdk-outpost/src/clients/solana/SolanaReserveSwapClient.ts +++ b/packages/sdk-outpost/src/clients/solana/SolanaReserveSwapClient.ts @@ -1,4 +1,4 @@ -import { BN, type AnchorProvider, type Program } from "@coral-xyz/anchor" +import { type AnchorProvider, type Program } from "@coral-xyz/anchor" import { getAssociatedTokenAddressSync, TOKEN_PROGRAM_ID @@ -15,11 +15,11 @@ import { utils as ethersUtils } from "ethers" import type { LiqsolCore } from "../../programs/solana/index.js" import { assertReserveSwapRequest, - assertReserveUnsigned64, type ReserveSwapRequest, type ReserveSwapSubmission, type SolanaSplReserveSwapRequest } from "../../reserves/index.js" +import { SolanaReserveAddresses } from "./SolanaReserveAddresses.js" const ConfirmationCommitment = "confirmed", ConfirmationPollIntervalMs = 1_500, @@ -27,20 +27,19 @@ const ConfirmationCommitment = "confirmed", confirmed: "confirmed", finalized: "finalized" } as const, - OutpostConfigSeed = Buffer.from("outpost_config"), - ReserveSeed = Buffer.from("reserve"), - ReserveVaultSeed = Buffer.from("reserve_vault"), - OutboundMessageBufferSeed = Buffer.from("outbound_message_buffer"), - Unsigned64ByteLength = 8, SwapDepositLog = /opp_outpost: SwapDeposit id=(\d+)\b/ /** Reserve-swap writes and balance reads for one verified Solana outpost. */ export class SolanaReserveSwapClient { + private readonly addresses: SolanaReserveAddresses + /** Create a Solana reserve-swap workflow bound to a verified deployment. */ constructor( private readonly provider: AnchorProvider, private readonly program: Program - ) {} + ) { + this.addresses = new SolanaReserveAddresses(program.programId) + } /** Build a native-SOL reserve-swap instruction without signing it. */ async createNativeInstruction( @@ -52,11 +51,12 @@ export class SolanaReserveSwapClient { .requestSwap(...this.instructionArguments(request)) .accounts({ user, - config: this.deriveAddress([OutpostConfigSeed]), - reserve: this.deriveReserveAddress(ReserveSeed, request), - outboundMessageBuffer: this.deriveAddress([ - OutboundMessageBufferSeed - ]), + config: this.addresses.outpostConfig(), + reserve: this.addresses.reserve({ + tokenCode: request.sourceTokenCode, + reserveCode: request.sourceReserveCode + }), + outboundMessageBuffer: this.addresses.outboundMessageBuffer(), systemProgram: SystemProgram.programId }) .instruction() @@ -88,14 +88,18 @@ export class SolanaReserveSwapClient { .requestSwapSpl(...this.instructionArguments(request)) .accounts({ user, - config: this.deriveAddress([OutpostConfigSeed]), - reserve: this.deriveReserveAddress(ReserveSeed, request), - reserveVault: this.deriveReserveAddress(ReserveVaultSeed, request), + config: this.addresses.outpostConfig(), + reserve: this.addresses.reserve({ + tokenCode: request.sourceTokenCode, + reserveCode: request.sourceReserveCode + }), + reserveVault: this.addresses.reserveVault({ + tokenCode: request.sourceTokenCode, + reserveCode: request.sourceReserveCode + }), mint: request.mint, userAta: userTokenAccount, - outboundMessageBuffer: this.deriveAddress([ - OutboundMessageBufferSeed - ]), + outboundMessageBuffer: this.addresses.outboundMessageBuffer(), tokenProgram: TOKEN_PROGRAM_ID }) .instruction() @@ -149,32 +153,6 @@ export class SolanaReserveSwapClient { return publicKey } - private deriveAddress(seeds: Buffer[]): PublicKey { - return PublicKey.findProgramAddressSync(seeds, this.program.programId)[0] - } - - private deriveReserveAddress( - seed: Buffer, - request: ReserveSwapRequest - ): PublicKey { - return this.deriveAddress([ - seed, - this.unsigned64Seed(request.sourceTokenCode, "sourceTokenCode"), - this.unsigned64Seed(request.sourceReserveCode, "sourceReserveCode") - ]) - } - - private unsigned64Seed( - value: ReserveSwapRequest["sourceTokenCode"], - field: string - ): Buffer { - return new BN(assertReserveUnsigned64(value, field).toString()).toArrayLike( - Buffer, - "le", - Unsigned64ByteLength - ) - } - private instructionArguments(request: ReserveSwapRequest) { return [ this.unsigned64(request.sourceTokenCode, "sourceTokenCode"), @@ -192,8 +170,8 @@ export class SolanaReserveSwapClient { private unsigned64( value: ReserveSwapRequest["sourceTokenCode"], field: string - ): BN { - return new BN(assertReserveUnsigned64(value, field).toString()) + ) { + return this.addresses.unsigned64(value, field) } private async submit( diff --git a/packages/sdk-outpost/src/clients/solana/index.ts b/packages/sdk-outpost/src/clients/solana/index.ts index 531fbf0..f3f3db7 100644 --- a/packages/sdk-outpost/src/clients/solana/index.ts +++ b/packages/sdk-outpost/src/clients/solana/index.ts @@ -1,3 +1,5 @@ export * from "./SolanaReserveSwapClient.js" +export * from "./SolanaReserveAddresses.js" +export * from "./SolanaReserveClient.js" export * from "./SolanaOutpostClient.js" export * from "./Types.js" diff --git a/packages/sdk-outpost/src/reserves/Types.ts b/packages/sdk-outpost/src/reserves/Types.ts index 7d9522b..3221cd9 100644 --- a/packages/sdk-outpost/src/reserves/Types.ts +++ b/packages/sdk-outpost/src/reserves/Types.ts @@ -1,5 +1,122 @@ import type { PublicKey } from "@solana/web3.js" import type { BigNumberish, BytesLike } from "ethers" +import type { ReserveManagerLib } from "../contracts/ethereum/generated/ReserveManager.js" + +/** Two-part identity shared by external-chain reserve custody clients. */ +export interface OutpostReserveIdentity { + /** Packed external token slug. */ + tokenCode: BigNumberish + /** Packed reserve discriminator slug. */ + reserveCode: BigNumberish +} + +/** Portable reserve fields validated before any external wallet prompt. */ +export interface ReserveCreateDefinition extends OutpostReserveIdentity { + /** External-chain amount escrowed by the creator. */ + externalTokenAmount: BigNumberish + /** Exact WIRE amount required to activate the reserve. */ + requestedWireAmount: BigNumberish + /** Bancor connector weight in basis points. */ + connectorWeightBps: number + /** User-facing reserve name. */ + name: string + /** User-facing reserve description. */ + description: string + /** Whether Wire restricts routing to a same-owner external counterpart. */ + isPrivate: boolean +} + +/** Ethereum reserve-creation request with the creator's AuthEx public key. */ +export interface EthereumReserveCreateRequest + extends ReserveCreateDefinition { + /** Compressed secp256k1 public key that derives to the connected signer. */ + creatorPubKey: BytesLike +} + +/** Confirmed external reserve lifecycle submission. */ +export interface OutpostReserveSubmission { + /** External-chain transaction hash or signature. */ + transactionId: string +} + +/** Chain-neutral lifecycle states exposed by external reserve clients. */ +export enum OutpostReserveStatus { + pending = "pending", + active = "active", + cancelled = "cancelled" +} + +/** Normalized Ethereum ReserveManager record. */ +export interface EthereumReserveRecord { + /** Packed external token slug. */ + tokenCode: bigint + /** Packed reserve discriminator slug. */ + reserveCode: bigint + /** Raw external amount committed at creation. */ + externalTokenAmount: bigint + /** Exact WIRE amount requested from the matcher. */ + requestedWireAmount: bigint + /** Connector weight in basis points. */ + connectorWeightBps: number + /** Local outpost lifecycle state. */ + status: OutpostReserveStatus + /** Ethereum creator address. */ + creator: string + /** Whether the record exists. */ + exists: boolean +} + +/** Solana reserve-creation request for the permissionless outpost path. */ +export interface SolanaReserveCreateRequest extends ReserveCreateDefinition { + /** Custody mint for SPL reserves, or a valid placeholder mint for native SOL. */ + mint: PublicKey + /** Creator token-account override. Defaults to the canonical ATA. */ + creatorTokenAccount?: PublicKey + /** Add an idempotent ATA instruction when no override is supplied. */ + ensureCreatorTokenAccount?: boolean +} + +/** Normalized Solana outpost reserve account. */ +export interface SolanaReserveRecord { + /** Reserve account PDA. */ + address: PublicKey + /** Reserve vault PDA. */ + vaultAddress: PublicKey + /** Packed external token slug. */ + tokenCode: bigint + /** Packed reserve discriminator slug. */ + reserveCode: bigint + /** Raw external amount committed at creation. */ + externalTokenAmount: bigint + /** Exact WIRE amount requested from the matcher. */ + requestedWireAmount: bigint + /** Connector weight in basis points. */ + connectorWeightBps: number + /** Local outpost lifecycle state. */ + status: OutpostReserveStatus + /** Solana wallet that created and can cancel the reserve. */ + creator: PublicKey + /** UTF-8 reserve display name. */ + name: string + /** UTF-8 reserve description. */ + description: string +} + +/** Token route configured by the Solana outpost authority. */ +export interface SolanaConfiguredReserveToken { + /** Packed token slug used in reserve instructions. */ + tokenCode: bigint + /** Configured SPL mint, or the all-zero native marker. */ + mint: PublicKey + /** Whether the token route represents native SOL. */ + isNative: boolean + /** Chain-side decimal precision used at the depot boundary. */ + decimals: number +} + +/** Permit signature accepted by Ethereum ReserveManager. */ +export type EthereumReservePermitSignature = + ReserveManagerLib.PermitSigStruct /** Confirmed source-outpost submission used to correlate a swap with Wire. */ export interface ReserveSwapSubmission { diff --git a/packages/sdk-outpost/src/reserves/Validation.ts b/packages/sdk-outpost/src/reserves/Validation.ts index 7d4ec2c..7f59d37 100644 --- a/packages/sdk-outpost/src/reserves/Validation.ts +++ b/packages/sdk-outpost/src/reserves/Validation.ts @@ -1,11 +1,23 @@ import { BigNumber, utils as ethersUtils } from "ethers" -import type { ReserveSwapRequest } from "./Types.js" +import { + MAX_CONNECTOR_WEIGHT_BPS, + MIN_CONNECTOR_WEIGHT_BPS +} from "@wireio/sdk-core/contracts/sysio/reserv/Constants" + +import type { + EthereumReserveCreateRequest, + ReserveCreateDefinition, + ReserveSwapRequest +} from "./Types.js" const MaximumUnsigned64 = BigNumber.from("18446744073709551615"), MinimumReserveValue = BigNumber.from(1), MinimumToleranceBps = 0, - MaximumToleranceBps = 10_000 + MaximumToleranceBps = 10_000, + MaximumReserveNameBytes = 64, + MaximumReserveDescriptionBytes = 256, + CompressedSecp256k1PublicKeyBytes = 33 /** Validate one value against the positive unsigned 64-bit protocol boundary. */ export function assertReserveUnsigned64( @@ -24,6 +36,69 @@ export function assertReserveUnsigned64( return parsed } +/** Validate portable reserve-creation fields before opening a wallet prompt. */ +export function assertReserveCreateDefinition( + definition: ReserveCreateDefinition +): void { + assertReserveUnsigned64(definition.tokenCode, "tokenCode") + assertReserveUnsigned64(definition.reserveCode, "reserveCode") + + let externalTokenAmount: BigNumber + try { + externalTokenAmount = BigNumber.from(definition.externalTokenAmount) + } catch (error: unknown) { + throw new Error("externalTokenAmount must be an integer.", { cause: error }) + } + if (externalTokenAmount.lt(MinimumReserveValue)) { + throw new Error("externalTokenAmount must be greater than zero.") + } + + assertReserveUnsigned64( + definition.requestedWireAmount, + "requestedWireAmount" + ) + if ( + !Number.isInteger(definition.connectorWeightBps) || + definition.connectorWeightBps < MIN_CONNECTOR_WEIGHT_BPS || + definition.connectorWeightBps > MAX_CONNECTOR_WEIGHT_BPS + ) { + throw new Error( + `connectorWeightBps must be an integer from ${MIN_CONNECTOR_WEIGHT_BPS} to ${MAX_CONNECTOR_WEIGHT_BPS}.` + ) + } + + const nameBytes = ethersUtils.toUtf8Bytes(definition.name).length + if (nameBytes === 0 || nameBytes > MaximumReserveNameBytes) { + throw new Error( + `name must contain 1 to ${MaximumReserveNameBytes} UTF-8 bytes.` + ) + } + + const descriptionBytes = ethersUtils.toUtf8Bytes( + definition.description + ).length + if (descriptionBytes > MaximumReserveDescriptionBytes) { + throw new Error( + `description must contain at most ${MaximumReserveDescriptionBytes} UTF-8 bytes.` + ) + } +} + +/** Validate Ethereum-specific reserve creation fields. */ +export function assertEthereumReserveCreateRequest( + request: EthereumReserveCreateRequest +): void { + assertReserveCreateDefinition(request) + if ( + ethersUtils.arrayify(request.creatorPubKey).length !== + CompressedSecp256k1PublicKeyBytes + ) { + throw new Error( + "creatorPubKey must be a 33-byte compressed secp256k1 public key." + ) + } +} + /** Validate portable reserve-swap fields before opening a wallet prompt. */ export function assertReserveSwapRequest(request: ReserveSwapRequest): void { assertReserveUnsigned64(request.sourceTokenCode, "sourceTokenCode") diff --git a/packages/sdk-outpost/tests/clients/ethereum/EthereumOutpostClient.test.ts b/packages/sdk-outpost/tests/clients/ethereum/EthereumOutpostClient.test.ts index 5e981f4..b56be6d 100644 --- a/packages/sdk-outpost/tests/clients/ethereum/EthereumOutpostClient.test.ts +++ b/packages/sdk-outpost/tests/clients/ethereum/EthereumOutpostClient.test.ts @@ -3,6 +3,7 @@ import { BigNumber, utils as ethersUtils, Wallet } from "ethers" import { EthereumContractName, EthereumOutpostClient, + EthereumReserveClient, EthereumReserveSwapClient, type ReserveSwapRequest } from "@wireio/sdk-outpost" @@ -84,6 +85,7 @@ describe("EthereumOutpostClient", () => { expect(reserveManager.address).toBe( profile.ethereum.contracts[EthereumContractName.ReserveManager].address ) + expect(client.reserves).toBeInstanceOf(EthereumReserveClient) expect(client.swaps).toBeInstanceOf(EthereumReserveSwapClient) expect(provider.getCode).toHaveBeenCalledTimes( Object.values(EthereumContractName).length * 2 diff --git a/packages/sdk-outpost/tests/clients/ethereum/EthereumReserveClient.test.ts b/packages/sdk-outpost/tests/clients/ethereum/EthereumReserveClient.test.ts new file mode 100644 index 0000000..a6b8b5b --- /dev/null +++ b/packages/sdk-outpost/tests/clients/ethereum/EthereumReserveClient.test.ts @@ -0,0 +1,202 @@ +import { + BigNumber, + Signer, + Wallet, + constants as ethersConstants, + providers, + utils as ethersUtils +} from "ethers" + +import { + EthereumReserveClient, + OutpostReserveStatus, + type EthereumReserveCreateRequest, + type ReserveManager +} from "@wireio/sdk-outpost" + +const ReserveTransactionHash = `0x${"11".repeat(32)}`, + ApprovalTransactionHash = `0x${"22".repeat(32)}`, + TokenAddress = "0x5c74c94173F05dA1720953407cbb920F3DF9f887", + CreatorAddress = "0x7412BC256355ABD22dD53De3a38E8995b5d4c1D1", + TransactionReceipt = { + logs: [], + status: 1 + } as unknown as providers.TransactionReceipt + +const request: EthereumReserveCreateRequest = { + tokenCode: 1, + reserveCode: 2, + externalTokenAmount: 3, + requestedWireAmount: 4, + connectorWeightBps: 5_000, + name: "Private ETH reserve", + description: "Same-owner external routing", + isPrivate: true, + creatorPubKey: `0x02${"11".repeat(32)}` +} + +function transactionFixture( + hash = ReserveTransactionHash, + wait: providers.TransactionResponse["wait"] = jest.fn( + async (): Promise => TransactionReceipt + ) +) { + return { + hash, + wait + } as unknown as providers.TransactionResponse +} + +function reserveManagerFixture() { + const transaction = transactionFixture(), + reserveManager = { + address: "0x18E317A7D70d8fBf8e6E893616b52390EbBdb629", + callStatic: { + create_reserve: jest.fn(async (): Promise => undefined), + requestReserveCreateErc20WithApproval: jest.fn( + async (): Promise => undefined + ), + requestReserveCreateErc20WithPermit: jest.fn( + async (): Promise => undefined + ) + }, + create_reserve: jest.fn(async () => transaction), + requestReserveCreateErc20WithApproval: jest.fn(async () => transaction), + requestReserveCreateErc20WithPermit: jest.fn(async () => transaction), + cancel_create_reserve: jest.fn(async () => transaction), + tokenAddressesByCode: jest.fn(async () => TokenAddress), + getReserve: jest.fn(async () => ({ + tokenCode: BigNumber.from(1), + reserveCode: BigNumber.from(2), + externalTokenAmount: BigNumber.from(3), + requestedWireAmount: BigNumber.from(4), + connectorWeightBps: 5_000, + status: 1, + creator: CreatorAddress, + exists: true + })) + } as unknown as ReserveManager + + return { reserveManager, transaction } +} + +class Erc20Signer extends Signer { + readonly provider = new providers.JsonRpcProvider() + readonly approvalWait = jest.fn( + async (): Promise => TransactionReceipt + ) + readonly approval = transactionFixture( + ApprovalTransactionHash, + this.approvalWait + ) + + async getAddress(): Promise { + return CreatorAddress + } + + async signMessage(): Promise { + return "0x" + } + + async signTransaction(): Promise { + return "0x" + } + + connect(): Signer { + return this + } + + async call(): Promise { + return ethersUtils.defaultAbiCoder.encode(["uint256"], [0]) + } + + async sendTransaction(): Promise { + return this.approval + } +} + +describe("EthereumReserveClient", () => { + it("creates a native pending reserve after static preflight", async () => { + const { reserveManager, transaction } = reserveManagerFixture(), + client = new EthereumReserveClient(reserveManager, Wallet.createRandom()) + + await expect(client.createNative(request)).resolves.toEqual({ + transactionId: ReserveTransactionHash + }) + expect(reserveManager.callStatic.create_reserve).toHaveBeenCalledTimes(1) + expect(reserveManager.create_reserve).toHaveBeenCalledTimes(1) + expect(transaction.wait).toHaveBeenCalledWith(1) + }) + + it("approves an ERC-20 before creating its pending reserve", async () => { + const { reserveManager } = reserveManagerFixture(), + signer = new Erc20Signer(), + client = new EthereumReserveClient(reserveManager, signer) + + const submission = await client.createErc20WithApproval( + request, + TokenAddress + ) + expect(submission).toEqual({ transactionId: ReserveTransactionHash }) + expect(signer.approvalWait).toHaveBeenCalledWith(1) + expect( + reserveManager.requestReserveCreateErc20WithApproval + ).toHaveBeenCalledTimes(1) + }) + + it("creates an ERC-20 reserve with a supplied permit", async () => { + const { reserveManager } = reserveManagerFixture(), + client = new EthereumReserveClient(reserveManager, Wallet.createRandom()) + + await expect( + client.createErc20WithPermit(request, { + deadline: 100, + v: 27, + r: ethersConstants.HashZero, + s: ethersConstants.HashZero + }) + ).resolves.toEqual({ transactionId: ReserveTransactionHash }) + expect( + reserveManager.callStatic.requestReserveCreateErc20WithPermit + ).toHaveBeenCalledTimes(1) + }) + + it("cancels a pending reserve and normalizes active local state", async () => { + const { reserveManager } = reserveManagerFixture(), + client = new EthereumReserveClient(reserveManager, Wallet.createRandom()) + + await expect( + client.cancel({ tokenCode: 1, reserveCode: 2 }) + ).resolves.toEqual({ transactionId: ReserveTransactionHash }) + await expect(client.get({ tokenCode: 1, reserveCode: 2 })).resolves.toEqual( + expect.objectContaining({ + tokenCode: 1n, + reserveCode: 2n, + status: OutpostReserveStatus.active, + creator: CreatorAddress, + exists: true + }) + ) + }) + + it("requires a signer and a configured ERC-20 route", async () => { + const { reserveManager } = reserveManagerFixture(), + provider = new providers.JsonRpcProvider(), + providerClient = new EthereumReserveClient(reserveManager, provider) + + await expect(providerClient.createNative(request)).rejects.toThrow( + "requires a connected signer" + ) + + reserveManager.tokenAddressesByCode = jest.fn( + async () => ethersConstants.AddressZero + ) + const signerClient = new EthereumReserveClient( + reserveManager, + new Erc20Signer() + ) + await expect(signerClient.createErc20WithApproval(request)).rejects.toThrow( + "No ERC-20 address is configured" + ) + }) +}) diff --git a/packages/sdk-outpost/tests/clients/solana/SolanaOutpostClient.test.ts b/packages/sdk-outpost/tests/clients/solana/SolanaOutpostClient.test.ts index 01a20ab..727943e 100644 --- a/packages/sdk-outpost/tests/clients/solana/SolanaOutpostClient.test.ts +++ b/packages/sdk-outpost/tests/clients/solana/SolanaOutpostClient.test.ts @@ -4,6 +4,7 @@ import { type ReserveSwapRequest, SolanaOutpostClient, SolanaProgramName, + SolanaReserveClient, SolanaReserveSwapClient, SolanaUpgradeableLoaderProgramId } from "@wireio/sdk-outpost" @@ -45,6 +46,7 @@ describe("SolanaOutpostClient", () => { expect(program.programId.toBase58()).toBe( profile.solana.programs[SolanaProgramName.liqsolCore].address ) + expect(client.reserves).toBeInstanceOf(SolanaReserveClient) expect(client.swaps).toBeInstanceOf(SolanaReserveSwapClient) }) diff --git a/packages/sdk-outpost/tests/clients/solana/SolanaReserveAddresses.test.ts b/packages/sdk-outpost/tests/clients/solana/SolanaReserveAddresses.test.ts new file mode 100644 index 0000000..83d0a95 --- /dev/null +++ b/packages/sdk-outpost/tests/clients/solana/SolanaReserveAddresses.test.ts @@ -0,0 +1,66 @@ +import { Keypair, PublicKey } from "@solana/web3.js" + +import { SolanaReserveAddresses } from "@wireio/sdk-outpost" + +const Unsigned64ByteLength = 8 + +function unsigned64Seed(value: bigint): Buffer { + const seed = Buffer.alloc(Unsigned64ByteLength) + seed.writeBigUInt64LE(value) + return seed +} + +describe("SolanaReserveAddresses", () => { + it("derives every reserve lifecycle PDA from the deployed program", () => { + const programId = Keypair.generate().publicKey, + addresses = new SolanaReserveAddresses(programId), + identity = { tokenCode: 1n, reserveCode: 2n }, + expectedReserve = PublicKey.findProgramAddressSync( + [ + Buffer.from("reserve"), + unsigned64Seed(1n), + unsigned64Seed(2n) + ], + programId + )[0], + expectedVault = PublicKey.findProgramAddressSync( + [ + Buffer.from("reserve_vault"), + unsigned64Seed(1n), + unsigned64Seed(2n) + ], + programId + )[0] + + expect(addresses.reserve(identity).equals(expectedReserve)).toBe(true) + expect(addresses.reserveVault(identity).equals(expectedVault)).toBe(true) + expect( + addresses + .outpostConfig() + .equals( + PublicKey.findProgramAddressSync( + [Buffer.from("outpost_config")], + programId + )[0] + ) + ).toBe(true) + expect( + addresses + .outboundMessageBuffer() + .equals( + PublicKey.findProgramAddressSync( + [Buffer.from("outbound_message_buffer")], + programId + )[0] + ) + ).toBe(true) + }) + + it("rejects reserve identity values outside the protocol u64 range", () => { + const addresses = new SolanaReserveAddresses(Keypair.generate().publicKey) + + expect(() => addresses.unsigned64(0, "tokenCode")).toThrow( + "tokenCode must be between 1 and uint64 max." + ) + }) +}) diff --git a/packages/sdk-outpost/tests/clients/solana/SolanaReserveClient.test.ts b/packages/sdk-outpost/tests/clients/solana/SolanaReserveClient.test.ts new file mode 100644 index 0000000..579c8c7 --- /dev/null +++ b/packages/sdk-outpost/tests/clients/solana/SolanaReserveClient.test.ts @@ -0,0 +1,185 @@ +import { BN, Program } from "@coral-xyz/anchor" +import { ASSOCIATED_TOKEN_PROGRAM_ID } from "@solana/spl-token" +import { Keypair, PublicKey } from "@solana/web3.js" + +import { + type LiqsolCore, + OutpostReserveStatus, + type SolanaReserveCreateRequest, + SolanaReserveClient, + liqsolCoreIdl +} from "@wireio/sdk-outpost" +import { + createOutpostDeploymentProfileFixture, + createSolanaProviderFixture +} from "../../Fixtures.js" + +const SubmittedSignature = "4".repeat(64), + AccountData = Buffer.alloc(8), + NativeTokenMarker = new PublicKey(new Uint8Array(32)) + +const request: SolanaReserveCreateRequest = { + tokenCode: 1, + reserveCode: 2, + externalTokenAmount: 3, + requestedWireAmount: 4, + connectorWeightBps: 5_000, + name: "Private USDC reserve", + description: "Same-owner external routing", + isPrivate: true, + mint: Keypair.generate().publicKey +} + +function clientFixture() { + const profile = createOutpostDeploymentProfileFixture(), + provider = createSolanaProviderFixture(profile), + address = profile.solana.programs.liqsolCore.address, + program = new Program( + { ...liqsolCoreIdl, address }, + provider + ), + client = new SolanaReserveClient(provider, program) + + return { client, program, provider } +} + +describe("SolanaReserveClient", () => { + it("builds permissionless reserve creation with an idempotent creator ATA", async () => { + const { client, program, provider } = clientFixture(), + instructions = await client.createInstructions(request) + + expect(instructions).toHaveLength(2) + expect( + instructions[0].programId.equals(ASSOCIATED_TOKEN_PROGRAM_ID) + ).toBe(true) + expect(instructions[1].programId.equals(program.programId)).toBe(true) + expect( + instructions[1].keys.some(key => + key.pubkey.equals(provider.wallet.publicKey) + ) + ).toBe(true) + }) + + it("uses an explicit creator token account without adding an ATA instruction", async () => { + const { client } = clientFixture(), + instructions = await client.createInstructions({ + ...request, + creatorTokenAccount: Keypair.generate().publicKey + }) + + expect(instructions).toHaveLength(1) + }) + + it("submits reserve creation and pending cancellation through the provider", async () => { + const { client, provider } = clientFixture(), + sendAndConfirm = jest + .spyOn(provider, "sendAndConfirm") + .mockResolvedValue(SubmittedSignature) + + await expect(client.create(request)).resolves.toEqual({ + transactionId: SubmittedSignature + }) + await expect( + client.cancel({ tokenCode: 1, reserveCode: 2 }) + ).resolves.toEqual({ transactionId: SubmittedSignature }) + expect(sendAndConfirm).toHaveBeenCalledTimes(2) + }) + + it("normalizes configured token routes and a local reserve record", async () => { + const { client, program, provider } = clientFixture(), + splMint = Keypair.generate().publicKey, + creator = Keypair.generate().publicKey + + jest.spyOn(provider.connection, "getAccountInfo").mockResolvedValue({ + data: AccountData, + executable: false, + lamports: 1, + owner: program.programId, + rentEpoch: 0 + }) + jest + .spyOn(program.coder.accounts, "decode") + .mockImplementation(accountName => + (accountName === "outpostConfig" + ? { + tokenAddressesByCode: [ + { tokenCode: new BN(1), mint: NativeTokenMarker }, + { tokenCode: new BN(2), mint: splMint } + ], + precisionByTokenCode: [ + { tokenCode: new BN(2), decimals: 6 } + ] + } + : { + tokenCode: new BN(2), + reserveCode: new BN(3), + externalTokenAmount: new BN(4), + requestedWireAmount: new BN(5), + connectorWeightBps: 5_000, + status: { active: {} }, + creator, + nameLen: 4, + nameBytes: [...Buffer.from("USDC"), ...new Uint8Array(60)], + descriptionLen: 7, + descriptionBytes: [ + ...Buffer.from("Private"), + ...new Uint8Array(249) + ] + }) as never + ) + + await expect(client.getConfiguredTokens()).resolves.toEqual([ + { + tokenCode: 1n, + mint: NativeTokenMarker, + isNative: true, + decimals: 9 + }, + { tokenCode: 2n, mint: splMint, isNative: false, decimals: 6 } + ]) + await expect( + client.get({ tokenCode: 2, reserveCode: 3 }) + ).resolves.toEqual( + expect.objectContaining({ + tokenCode: 2n, + reserveCode: 3n, + status: OutpostReserveStatus.active, + creator, + name: "USDC", + description: "Private" + }) + ) + }) + + it("returns null for an absent account and rejects invalid creation values", async () => { + const { client, provider } = clientFixture() + jest.spyOn(provider.connection, "getAccountInfo").mockResolvedValue(null) + + await expect( + client.get({ tokenCode: 1, reserveCode: 2 }) + ).resolves.toBeNull() + await expect( + client.createInstructions({ ...request, externalTokenAmount: 0 }) + ).rejects.toThrow("externalTokenAmount must be greater than zero.") + }) + + it("rejects a configured SPL reserve route without chain precision", async () => { + const { client, program, provider } = clientFixture(), + splMint = Keypair.generate().publicKey + jest.spyOn(provider.connection, "getAccountInfo").mockResolvedValue({ + data: AccountData, + executable: false, + lamports: 1, + owner: program.programId, + rentEpoch: 0 + }) + jest.spyOn(program.coder.accounts, "decode").mockReturnValue({ + tokenAddressesByCode: [{ tokenCode: new BN(2), mint: splMint }], + precisionByTokenCode: [] + } as never) + + await expect(client.getConfiguredTokens()).rejects.toThrow( + "Solana reserve token 2 has no configured precision." + ) + }) +}) diff --git a/packages/sdk-outpost/tests/reserves/Validation.test.ts b/packages/sdk-outpost/tests/reserves/Validation.test.ts index 61914c4..61088b9 100644 --- a/packages/sdk-outpost/tests/reserves/Validation.test.ts +++ b/packages/sdk-outpost/tests/reserves/Validation.test.ts @@ -1,9 +1,29 @@ import { + assertEthereumReserveCreateRequest, + assertReserveCreateDefinition, assertReserveSwapRequest, assertReserveUnsigned64, + type EthereumReserveCreateRequest, + type ReserveCreateDefinition, type ReserveSwapRequest } from "@wireio/sdk-outpost" +const reserveDefinition: ReserveCreateDefinition = { + tokenCode: 1, + reserveCode: 2, + externalTokenAmount: 3, + requestedWireAmount: 4, + connectorWeightBps: 5_000, + name: "Private ETH reserve", + description: "Same-owner external routing", + isPrivate: true +} + +const ethereumRequest: EthereumReserveCreateRequest = { + ...reserveDefinition, + creatorPubKey: `0x02${"11".repeat(32)}` +} + const request: ReserveSwapRequest = { sourceTokenCode: 1, sourceReserveCode: 2, @@ -17,6 +37,31 @@ const request: ReserveSwapRequest = { } describe("reserve swap validation", () => { + it("accepts portable and Ethereum reserve creation requests", () => { + expect(() => assertReserveCreateDefinition(reserveDefinition)).not.toThrow() + expect(() => + assertEthereumReserveCreateRequest(ethereumRequest) + ).not.toThrow() + }) + + it("rejects invalid reserve creation metadata and creator keys", () => { + expect(() => + assertReserveCreateDefinition({ + ...reserveDefinition, + connectorWeightBps: 10_000 + }) + ).toThrow("connectorWeightBps") + expect(() => + assertReserveCreateDefinition({ ...reserveDefinition, name: "" }) + ).toThrow("name must contain") + expect(() => + assertEthereumReserveCreateRequest({ + ...ethereumRequest, + creatorPubKey: "0x02" + }) + ).toThrow("33-byte compressed") + }) + it("accepts a portable positive request", () => { expect(() => assertReserveSwapRequest(request)).not.toThrow() expect(assertReserveUnsigned64(8, "value").toNumber()).toBe(8) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 64b7926..b9c1b0c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -212,6 +212,9 @@ importers: '@solana/web3.js': specifier: ^1.98.4 version: 1.98.4(bufferutil@4.1.0)(typescript@6.0.2)(utf-8-validate@6.0.6) + '@wireio/sdk-core': + specifier: workspace:* + version: link:../sdk-core ethers: specifier: ^5.8.0 version: 5.8.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) diff --git a/scripts/sdk-outpost/verify-package.mjs b/scripts/sdk-outpost/verify-package.mjs index 3f2b0e3..e4fd026 100644 --- a/scripts/sdk-outpost/verify-package.mjs +++ b/scripts/sdk-outpost/verify-package.mjs @@ -13,10 +13,12 @@ const packageJson = await readJson(path.join(PackagePath, "package.json")), ExpectedPublishedFiles = ["lib/cjs", "lib/esm", "README.md"], ExpectedExports = [ "EthereumOutpostClient", + "EthereumReserveClient", "OutpostArtifactManifests", "OutpostClient", "OutpostDeploymentVerifier", "SolanaOutpostClient", + "SolanaReserveClient", "assertOutpostArtifactCompatibility", "parseOutpostDeploymentProfile" ] From 2111ab1b53bd54007fe7fd706b932936b5dc6067 Mon Sep 17 00:00:00 2001 From: joshglogau Date: Fri, 7 Aug 2026 14:12:57 -0400 Subject: [PATCH 3/3] fix(sdk-outpost): harden reserve creation preflight --- packages/sdk-outpost/README.md | 6 ++++++ .../clients/ethereum/EthereumReserveClient.ts | 20 +++++++++++++----- .../src/clients/solana/SolanaReserveClient.ts | 13 +++++++----- packages/sdk-outpost/src/reserves/Types.ts | 2 +- .../sdk-outpost/src/reserves/Validation.ts | 21 +++++++++++++------ .../ethereum/EthereumReserveClient.test.ts | 12 +++++++++++ .../solana/SolanaReserveClient.test.ts | 3 +++ .../tests/reserves/Validation.test.ts | 12 +++++++++++ 8 files changed, 72 insertions(+), 17 deletions(-) diff --git a/packages/sdk-outpost/README.md b/packages/sdk-outpost/README.md index 005fc1f..269c076 100644 --- a/packages/sdk-outpost/README.md +++ b/packages/sdk-outpost/README.md @@ -164,6 +164,12 @@ token discovery, instruction assembly, creation, pending cancellation, address derivation, and local reserve reads. `cancel` is valid only while creation is pending and drives the protocol refund path. +The all-zero mint returned for a configured native SOL route is protocol +metadata, not an Anchor account. The current `create_reserve` account context +still requires a real placeholder SPL mint and the creator's token account for +native SOL creation. Consumers that have not provisioned those accounts should +select a configured non-native SPL route, as in the example above. + Private is a routing constraint, not access control or confidentiality. Private reserves cannot use WIRE as a swap endpoint; when either external route leg is private, Wire requires both active reserves to have the same non-empty owner. diff --git a/packages/sdk-outpost/src/clients/ethereum/EthereumReserveClient.ts b/packages/sdk-outpost/src/clients/ethereum/EthereumReserveClient.ts index cbec80b..80d4076 100644 --- a/packages/sdk-outpost/src/clients/ethereum/EthereumReserveClient.ts +++ b/packages/sdk-outpost/src/clients/ethereum/EthereumReserveClient.ts @@ -2,6 +2,7 @@ import { constants as ethersConstants, Contract, Signer, + utils as ethersUtils, type providers } from "ethers" import { match } from "ts-pattern" @@ -67,17 +68,26 @@ export class EthereumReserveClient { assertEthereumReserveCreateRequest(request) const signer = this.assertSigner(), owner = await signer.getAddress(), - resolvedTokenAddress = - tokenAddress ?? - (await this.reserveManager.tokenAddressesByCode(request.tokenCode)) + configuredTokenAddress = await this.reserveManager.tokenAddressesByCode( + request.tokenCode + ) - if (resolvedTokenAddress === ethersConstants.AddressZero) { + if (configuredTokenAddress === ethersConstants.AddressZero) { throw new Error( `No ERC-20 address is configured for tokenCode ${request.tokenCode.toString()}.` ) } + if ( + tokenAddress != null && + ethersUtils.getAddress(tokenAddress) !== + ethersUtils.getAddress(configuredTokenAddress) + ) { + throw new Error( + `ERC-20 address ${tokenAddress} does not match the configured route ${configuredTokenAddress}.` + ) + } - const token = new Contract(resolvedTokenAddress, Erc20Interface, signer), + const token = new Contract(configuredTokenAddress, Erc20Interface, signer), allowance = await token.allowance(owner, this.reserveManager.address) if (allowance.lt(request.externalTokenAmount)) { const approval = await token.approve( diff --git a/packages/sdk-outpost/src/clients/solana/SolanaReserveClient.ts b/packages/sdk-outpost/src/clients/solana/SolanaReserveClient.ts index d8e9fa9..da10905 100644 --- a/packages/sdk-outpost/src/clients/solana/SolanaReserveClient.ts +++ b/packages/sdk-outpost/src/clients/solana/SolanaReserveClient.ts @@ -31,7 +31,8 @@ import { import { SolanaReserveAddresses } from "./SolanaReserveAddresses.js" const PublicKeyByteLength = 32, - NativeSolanaDecimals = 9 + NativeSolanaDecimals = 9, + NativeTokenMarker = new PublicKey(new Uint8Array(PublicKeyByteLength)) type LiqsolCoreAccounts = IdlAccounts @@ -63,14 +64,11 @@ export class SolanaReserveClient { entry.tokenCode.toString(), entry.decimals ]) - ), - nativeTokenMarker = new PublicKey( - new Uint8Array(PublicKeyByteLength) ) return config.tokenAddressesByCode.map(entry => { const tokenCode = entry.tokenCode.toString(), - isNative = entry.mint.equals(nativeTokenMarker), + isNative = entry.mint.equals(NativeTokenMarker), configuredDecimals = precisionByTokenCode.get(tokenCode) if (!isNative && configuredDecimals == null) { throw new Error( @@ -97,6 +95,11 @@ export class SolanaReserveClient { ): Promise { assertReserveCreateDefinition(request) assertReserveUnsigned64(request.externalTokenAmount, "externalTokenAmount") + if (request.mint.equals(NativeTokenMarker)) { + throw new Error( + "Native SOL reserve creation requires a real placeholder SPL mint and creator token account; the all-zero native marker is configuration metadata only." + ) + } const creator = this.assertWallet(), { creatorTokenAccount = getAssociatedTokenAddressSync( diff --git a/packages/sdk-outpost/src/reserves/Types.ts b/packages/sdk-outpost/src/reserves/Types.ts index 3221cd9..9aa28c2 100644 --- a/packages/sdk-outpost/src/reserves/Types.ts +++ b/packages/sdk-outpost/src/reserves/Types.ts @@ -68,7 +68,7 @@ export interface EthereumReserveRecord { /** Solana reserve-creation request for the permissionless outpost path. */ export interface SolanaReserveCreateRequest extends ReserveCreateDefinition { - /** Custody mint for SPL reserves, or a valid placeholder mint for native SOL. */ + /** Custody mint for SPL reserves, or a real placeholder SPL mint for native SOL. */ mint: PublicKey /** Creator token-account override. Defaults to the canonical ATA. */ creatorTokenAccount?: PublicKey diff --git a/packages/sdk-outpost/src/reserves/Validation.ts b/packages/sdk-outpost/src/reserves/Validation.ts index 7f59d37..55bb2c8 100644 --- a/packages/sdk-outpost/src/reserves/Validation.ts +++ b/packages/sdk-outpost/src/reserves/Validation.ts @@ -17,7 +17,13 @@ const MaximumUnsigned64 = BigNumber.from("18446744073709551615"), MaximumToleranceBps = 10_000, MaximumReserveNameBytes = 64, MaximumReserveDescriptionBytes = 256, - CompressedSecp256k1PublicKeyBytes = 33 + CompressedSecp256k1PublicKeyBytes = 33, + CompressedSecp256k1PublicKeyPrefix = { + even: 2, + odd: 3 + } as const, + InvalidCompressedSecp256k1PublicKeyMessage = + "creatorPubKey must be a 33-byte compressed secp256k1 public key." /** Validate one value against the positive unsigned 64-bit protocol boundary. */ export function assertReserveUnsigned64( @@ -89,13 +95,16 @@ export function assertEthereumReserveCreateRequest( request: EthereumReserveCreateRequest ): void { assertReserveCreateDefinition(request) + if (!ethersUtils.isBytesLike(request.creatorPubKey)) { + throw new Error(InvalidCompressedSecp256k1PublicKeyMessage) + } + const creatorPublicKey = ethersUtils.arrayify(request.creatorPubKey) if ( - ethersUtils.arrayify(request.creatorPubKey).length !== - CompressedSecp256k1PublicKeyBytes + creatorPublicKey.length !== CompressedSecp256k1PublicKeyBytes || + (creatorPublicKey[0] !== CompressedSecp256k1PublicKeyPrefix.even && + creatorPublicKey[0] !== CompressedSecp256k1PublicKeyPrefix.odd) ) { - throw new Error( - "creatorPubKey must be a 33-byte compressed secp256k1 public key." - ) + throw new Error(InvalidCompressedSecp256k1PublicKeyMessage) } } diff --git a/packages/sdk-outpost/tests/clients/ethereum/EthereumReserveClient.test.ts b/packages/sdk-outpost/tests/clients/ethereum/EthereumReserveClient.test.ts index a6b8b5b..cac3fb7 100644 --- a/packages/sdk-outpost/tests/clients/ethereum/EthereumReserveClient.test.ts +++ b/packages/sdk-outpost/tests/clients/ethereum/EthereumReserveClient.test.ts @@ -199,4 +199,16 @@ describe("EthereumReserveClient", () => { "No ERC-20 address is configured" ) }) + + it("rejects an ERC-20 address that differs from the configured route", async () => { + const { reserveManager } = reserveManagerFixture(), + signer = new Erc20Signer(), + client = new EthereumReserveClient(reserveManager, signer), + differentTokenAddress = Wallet.createRandom().address + + await expect( + client.createErc20WithApproval(request, differentTokenAddress) + ).rejects.toThrow("does not match the configured route") + expect(signer.approvalWait).not.toHaveBeenCalled() + }) }) diff --git a/packages/sdk-outpost/tests/clients/solana/SolanaReserveClient.test.ts b/packages/sdk-outpost/tests/clients/solana/SolanaReserveClient.test.ts index 579c8c7..f8baccc 100644 --- a/packages/sdk-outpost/tests/clients/solana/SolanaReserveClient.test.ts +++ b/packages/sdk-outpost/tests/clients/solana/SolanaReserveClient.test.ts @@ -161,6 +161,9 @@ describe("SolanaReserveClient", () => { await expect( client.createInstructions({ ...request, externalTokenAmount: 0 }) ).rejects.toThrow("externalTokenAmount must be greater than zero.") + await expect( + client.createInstructions({ ...request, mint: NativeTokenMarker }) + ).rejects.toThrow("requires a real placeholder SPL mint") }) it("rejects a configured SPL reserve route without chain precision", async () => { diff --git a/packages/sdk-outpost/tests/reserves/Validation.test.ts b/packages/sdk-outpost/tests/reserves/Validation.test.ts index 61088b9..e2ce32e 100644 --- a/packages/sdk-outpost/tests/reserves/Validation.test.ts +++ b/packages/sdk-outpost/tests/reserves/Validation.test.ts @@ -60,6 +60,18 @@ describe("reserve swap validation", () => { creatorPubKey: "0x02" }) ).toThrow("33-byte compressed") + expect(() => + assertEthereumReserveCreateRequest({ + ...ethereumRequest, + creatorPubKey: `0x04${"11".repeat(32)}` + }) + ).toThrow("33-byte compressed") + expect(() => + assertEthereumReserveCreateRequest({ + ...ethereumRequest, + creatorPubKey: "not-hex" + }) + ).toThrow("33-byte compressed") }) it("accepts a portable positive request", () => {