Skip to main content
Skip to main content

xStocks Settlement

A quote that reaches SELECTED carries a Silhouette-signed Permit2 authorization: the spender, permitSignature, and settlementDeadline fields on the quote, delivered as described on the quoting page. You relay that authorization on-chain by calling fill() on the wrapper you deployed. The wrapper consumes the authorization to pull the taker token from the omnibus, produces the maker leg (from its own inventory, or through Backed's AtomicSwap) and emits the Filled event Silhouette observes, all in one transaction that either completes or reverts entirely. This page specifies exactly what Silhouette signs, what the wrapper must do, and what Silhouette verifies before it credits the fill.

What Silhouette signs

For each selected quote, Silhouette signs one EIP-712 PermitWitnessTransferFrom under the canonical Permit2 domain:

EIP712Domain {
name: "Permit2"
chainId: 999 // illustrative: HyperEVM mainnet; confirm the value for your deployment during onboarding
verifyingContract: 0x000000000022D473030F116dDEE9F6B43aC78BA3
}

version is intentionally absent; Permit2's EIP712Domain omits it.

Permit2 forms its primary typehash by concatenating a fixed PermitWitnessTransferFrom stub with the wrapper's byte-exact witness type string, passed as the witnessTypeString argument at call time. Your wrapper must pass the same string it was compiled with, character for character; one byte of drift and the recovered signer no longer matches, so the fill reverts before anything moves.

The inventory witness type string is:

InventoryWitness witness)InventoryWitness(address makerToken,uint256 makerAmount)TokenPermissions(address token,uint256 amount)

producing the full type:

PermitWitnessTransferFrom(TokenPermissions permitted,address spender,uint256 nonce,uint256 deadline,InventoryWitness witness)InventoryWitness(address makerToken,uint256 makerAmount)TokenPermissions(address token,uint256 amount)

The xChange witness type string is:

XchangeWitness witness)SwapMessage(bytes32 quoteId,uint256 expiration,Transfer incomingTransfer,Transfer outgoingTransfer)TokenPermissions(address token,uint256 amount)Transfer(address from,address to,address token,uint256 amount)XchangeWitness(SwapMessage swap,uint256 makerAmount)

producing the full type:

PermitWitnessTransferFrom(TokenPermissions permitted,address spender,uint256 nonce,uint256 deadline,XchangeWitness witness)SwapMessage(bytes32 quoteId,uint256 expiration,Transfer incomingTransfer,Transfer outgoingTransfer)TokenPermissions(address token,uint256 amount)Transfer(address from,address to,address token,uint256 amount)XchangeWitness(SwapMessage swap,uint256 makerAmount)

The witness carries the quoted maker amount alongside the swap because the swap may deliver more than the omnibus is owed. Binding it in the signature is what stops the amount being one your submitting address gets to choose.

Each field of the permit is bound as follows:

Permit fieldValue Silhouette signs
permitted.tokenThe taker token (USDC on a BUY; the base token on a SELL).
permitted.amountExactly the quoted makerReceives amount, in base units. In xChange mode this is what leaves the omnibus, whatever share of it your swap routes to Backed.
spenderYour wrapper contract address, the spender field on the SELECTED quote.
nonceThe quoteId cast to uint256: a UUIDv7 right-aligned into 32 bytes, high 128 bits zero.
deadlineUnix seconds. Inventory: the RFQ's settlementDeadline, echoed as the quote's settlementDeadline field. xChange: swapMessage.expiration, Backed's own value, which your wrapper rebuilds this deadline from.
witnessInventory: the EIP-712 hash of InventoryWitness(makerToken, makerAmount). xChange: the EIP-712 hash of XchangeWitness(swap, makerAmount) — the exact Backed SwapMessage Silhouette validated, and the share of its outgoing leg the omnibus is owed.

The crucial mechanic: the wrapper does not pass spender to permitWitnessTransferFrom. Permit2 takes it from msg.sender and checks it against the value bound into the signature, so Silhouette's signature is usable only by the one wrapper it names; no other contract can consume it. The nonce doubles as the reconciliation join key: it is the quote id, and it ties the on-chain fill back to the RFQ.

The SELECTED quote carries only the signature, the spender, and the deadline. You reconstruct the rest of the permit and the witness from the quote's own legs: the leg amounts in base units, and each leg's token symbol resolved to its ERC-20 address with GET /v1/rfq/tokens.

The Filled event

Every approved wrapper emits the same Filled event, byte-for-byte identical; Silhouette scans this exact signature across every approved wrapper address:

event Filled(
bytes32 indexed quoteId, // = Silhouette's quoteId (UUIDv7 zero-padded to 32 bytes)
address indexed payer, // = omnibus
address indexed maker, // freeform maker identity (e.g. a maker-controlled EOA)
address takerToken, // USDC for a Buy
address makerToken, // xStock for a Buy
uint256 takerAmount, // amount of takerToken pulled from payer
uint256 makerAmount // amount of makerToken delivered to payer
);

quoteId is Silhouette's quote id (the UUIDv7 zero-padded to 32 bytes, the same value encoded into the Permit2 nonce) and is the join key back to the quote. In xChange mode it is Silhouette's id, never Backed's: Backed's SwapMessage carries its own quoteId for its own replay map inside the AtomicSwap contract, and the two identifiers never mix. payer is the omnibus. maker is a maker-controlled address used for indexing only; it gates nothing.

The inventory wrapper

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;

interface IInventoryWrapper {
error InsufficientInventory();
error TakerDeliveryMismatch();
error MakerDeliveryMismatch();
error InvalidMaker();
error InvalidOmnibus();
error InvalidOneUnitRoundingToken();
error InvalidWithdrawalRecipient();
error UnauthorisedMaker();
error UnauthorisedPayer();

function omnibus() external view returns (address);
function maker() external view returns (address);
function oneUnitRoundingTokens(address token) external view returns (bool);

event Filled(
bytes32 indexed quoteId,
address indexed payer,
address indexed maker,
address takerToken,
address makerToken,
uint256 takerAmount,
uint256 makerAmount
);

event Withdrawn(address indexed token, address indexed to, uint256 amount);

struct InventoryWitness {
address makerToken;
uint256 makerAmount;
}

/// @param payer Omnibus address that signed the Permit2 message.
/// @param takerToken Token pulled from `payer` (e.g. USDC).
/// @param takerAmount Amount of `takerToken` to pull.
/// @param witness Maker token and amount bound into the Permit2 witness.
/// @param quoteId Silhouette's quote id; encoded into the Permit2 `nonce`.
/// @param deadline Permit2 deadline (absolute unix seconds).
/// @param permitSig 65-byte ECDSA from `payer` over PermitWitnessTransferFrom.
function fill(
address payer,
address takerToken,
uint256 takerAmount,
InventoryWitness calldata witness,
bytes32 quoteId,
uint256 deadline,
bytes calldata permitSig
) external;

/// Transfers maker-owned inventory or accumulated proceeds out
/// of the wrapper. Only the immutable maker may call.
function withdraw(address token, uint256 amount, address to) external;
}

A conforming fill() performs the following, atomically, in this order:

  1. Require payer to equal the omnibus pinned by the constructor. Submission stays permissionless even so: the omnibus's Permit2 signature binds the complete maker leg, and another Permit2 owner cannot self-sign a dust payment that drains the wrapper's pre-funded inventory; its own signature would name itself as payer and fail this check.
  2. Hash InventoryWitness(makerToken, makerAmount) and call Permit2.permitWitnessTransferFrom(...) with the fixed inventory witness type string above, taking the taker token from payer into address(this).
  3. Measure the wrapper's taker-token balance and require the exact takerAmount increase. Tokens that charge transfer fees or rebase during the call are unsupported and revert with TakerDeliveryMismatch.
  4. Verify the wrapper's maker-token inventory is at least witness.makerAmount; revert InsufficientInventory otherwise.
  5. Transfer witness.makerAmount to payer through a safe ERC-20 call that accepts a true return or no return data, and require the payer's balance increase to equal that amount. A maker token pinned in the wrapper's immutable one-unit-rounding set may instead deliver one base unit less (see the one-unit rounding tolerance). An ordinary-token shortfall, a larger xStock shortfall, or any over-delivery reverts with MakerDeliveryMismatch.
  6. Emit Filled(quoteId, payer, maker, takerToken, witness.makerToken, takerAmount, witness.makerAmount).

The wrapper delivers from its own pre-funded balance, so that balance caps the size of any single fill, and topping it up is your operational responsibility. The maker-only withdraw(token, amount, to) collects taker-token proceeds or rebalances inventory; it never changes the destination of a fill; it transfers only assets the wrapper already holds.

The xChange wrapper

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;

import {IBackedAtomicSwap} from "./IBackedAtomicSwap.sol";

interface IXstocksXchangeWrapper {
error UnauthorizedSubmitter();
error IncomingFromMismatch();
error IncomingToMismatch();
error OutgoingFromMismatch();
error OutgoingToMismatch();
error TakerDeliveryMismatch();
error MakerDeliveryMismatch();
error TakerLegAboveQuote();
error MakerLegBelowQuote();
error SurplusRequiresWrapperDelivery();
error RoundingTokenRequiresSurplus();
error InvalidWithdrawalRecipient();
error ReentrantCall();
error InvalidOneUnitRoundingToken();

/// @notice The amounts Silhouette quoted. The swap message does not
/// determine them: `takerAmount` is what leaves the omnibus and
/// `makerAmount` is what returns to it. You keep the difference
/// on either leg.
struct QuotedLegs {
uint256 takerAmount;
uint256 makerAmount;
}

function oneUnitRoundingTokens(address token) external view returns (bool);

/// @return Backed's account, fixed at deployment. Both legs of every
/// accepted `SwapMessage` must name it, and it must equal the
/// Backed transfer address registered for this maker with
/// Silhouette.
function xstocksTransferAddress() external view returns (address);

event Filled(
bytes32 indexed quoteId,
address indexed payer,
address indexed maker,
address takerToken,
address makerToken,
uint256 takerAmount,
uint256 makerAmount
);

event Withdrawn(address indexed token, address indexed to, uint256 amount);

/// @param omnibus Omnibus address that signed the Permit2 message.
/// @param silhouetteQuoteId Silhouette quote id encoded into the Permit2
/// nonce. Distinct from the xStocks swap quote id.
/// @param xstocksSwapParams Backed-signed xStocks `SwapMessage`.
/// @param quoted The amounts Silhouette quoted. Any other pair
/// fails Permit2 signature verification.
/// @param xstocksSignature Backed's EIP-712 signature over the xStocks swap.
/// @param omnibusPermitSignature Omnibus signature over PermitWitnessTransferFrom.
function fill(
address omnibus,
bytes32 silhouetteQuoteId,
IBackedAtomicSwap.SwapMessage calldata xstocksSwapParams,
QuotedLegs calldata quoted,
bytes calldata xstocksSignature,
bytes calldata omnibusPermitSignature
) external;

/// @notice Collect a retained spread. Maker-only; the zero recipient is
/// refused.
function withdraw(address token, uint256 amount, address to) external;
}

A conforming fill() performs the following, atomically, in this order:

  1. Permit only the configured maker submitter, and reject reentrant calls.
  2. Require the four party bindings: incomingTransfer.from == address(this), incomingTransfer.to == xstocksTransferAddress, outgoingTransfer.from == xstocksTransferAddress, and outgoingTransfer.to to be either omnibus or address(this). Then require the two amount bounds: incomingTransfer.amount <= quoted.takerAmount and outgoingTransfer.amount >= quoted.makerAmount. Where the outgoing leg exceeds the quote, require outgoingTransfer.to == address(this); the omnibus is owed the quote and no more, so a surplus it received would fail reconciliation. A maker token in the immutable one-unit-rounding set routed through the wrapper must carry a surplus, which is what covers the base unit each of the two share conversions can round away.
  3. Hash XchangeWitness(SwapMessage swap,uint256 makerAmount) — the complete SwapMessage struct hash and the omnibus's share — and call Permit2.permitWitnessTransferFrom(...) with permitted.token = swap.incomingTransfer.token, permitted.amount = quoted.takerAmount, nonce = uint256(silhouetteQuoteId), and deadline = swap.expiration, pulling the taker token from omnibus into address(this).
  4. Record the Backed transfer account's taker-token balance and the payer's maker-token balance. Set the AtomicSwap allowance safely to the exact incoming amount, including a zero-first fallback for tokens that require it.
  5. Call atomicSwap.executeSwap(swap, signature, emptyPermit), then reset the allowance to zero.
  6. Require the Backed transfer account's taker-token balance to have increased by exactly incomingTransfer.amount. If the maker token was delivered to the wrapper, forward quoted.makerAmount to omnibus through an optional-return safe transfer, keeping the rest. Require the omnibus's maker-token balance increase to equal quoted.makerAmount, or one base unit less for a maker token pinned in the one-unit-rounding set. An ordinary-token shortfall, a larger xStock shortfall, or any over-delivery to the omnibus reverts.
  7. Emit Filled(silhouetteQuoteId, omnibus, maker, swap.incomingTransfer.token, swap.outgoingTransfer.token, quoted.takerAmount, quoted.makerAmount) — what the omnibus paid and received, not what passed through Backed.

Whatever you retain stays in the wrapper until you call withdraw(token, amount, to). Settlement never leaves the omnibus's own funds behind, so that path reaches only what you earned.

An xChange settlement transaction must invoke exactly one successful fill(). Batching several fills in one transaction is invalid even when every Filled event has the canonical shape, because Silhouette reconciles the transaction receipt's net omnibus movements against one winning quote; a receipt carrying two fills' movements matches neither.

The Permit2 and Backed interfaces

Your wrapper calls two external contracts. Permit2's signature-transfer entrypoint:

// Canonical Uniswap Permit2 deployment, identical on every EVM chain.
// Address on HyperEVM: 0x000000000022D473030F116dDEE9F6B43aC78BA3
interface ISignatureTransfer {
struct TokenPermissions {
address token;
uint256 amount;
}

struct PermitTransferFrom {
TokenPermissions permitted;
uint256 nonce;
uint256 deadline;
}

struct SignatureTransferDetails {
address to;
uint256 requestedAmount;
}

function permitWitnessTransferFrom(
PermitTransferFrom memory permit,
SignatureTransferDetails calldata transferDetails,
address owner,
bytes32 witness,
string calldata witnessTypeString,
bytes calldata signature
) external;
}

And Backed's AtomicSwap, xChange mode only. Note the Transfer field order, (from, to, token, amount), and that executeSwap takes three arguments; the wrapper passes an empty Permit:

interface IBackedAtomicSwap {
struct Transfer {
address from;
address to;
address token;
uint256 amount;
}

struct SwapMessage {
bytes32 quoteId; // Backed's own per-quote id (distinct from Silhouette's quoteId)
uint256 expiration; // unix seconds; at or before the RFQ's settlementDeadline
Transfer incomingTransfer;
Transfer outgoingTransfer;
}

struct Permit {
address owner;
uint256 deadline;
uint8 v;
bytes32 r;
bytes32 s;
}

function executeSwap(
SwapMessage calldata swap,
bytes calldata signature,
Permit calldata permit
) external;
}

Backed signs the SwapMessage under this EIP-712 domain, with the AtomicSwap deployment as the verifying contract (its address is supplied during onboarding):

EIP712Domain {
name: "AtomicSwap"
version: "1"
chainId: 999 // illustrative; confirm during onboarding
verifyingContract: <Backed AtomicSwap address on HyperEVM>
}

The typehashes are:

SwapMessage(bytes32 quoteId,uint256 expiration,Transfer incomingTransfer,Transfer outgoingTransfer)Transfer(address from,address to,address token,uint256 amount)
Transfer(address from,address to,address token,uint256 amount)

What Silhouette validates before signing

Before signing its Permit2 over an xChange quote's SwapMessage, Silhouette validates each of the following off-chain:

  • The Backed signature recovers to the signer registered for you at onboarding.
  • incomingTransfer.from is your wrapper.
  • incomingTransfer.to and outgoingTransfer.from are the registered Backed transfer address.
  • incomingTransfer.token is the RFQ's taker token, and incomingTransfer.amount is at most the quoted taker amount in base units.
  • outgoingTransfer.token is the RFQ's maker token, and outgoingTransfer.amount is at least the quoted maker amount in base units.
  • outgoingTransfer.to is the omnibus or your wrapper, and must be your wrapper wherever the outgoing leg exceeds the quote.
  • expiration is still in the future and at or before the RFQ's settlementDeadline. Backed's value is adopted rather than replaced: Silhouette signs its Permit2 with it, because the wrapper rebuilds its own deadline from the same field.

One distinction matters more than the rest: the Backed transfer address is Backed's own account and is the counterparty on both legs; the AtomicSwap address is the EIP-712 verifying contract and is never a transfer party. They are separate values, and neither leg is ever compared with the AtomicSwap address. Binding both legs to a registered address (rather than only requiring that they agree with each other) is what stops a Backed-signed message from routing the swap through an account the maker chose. Your wrapper enforces the same binding on-chain against its own xstocksTransferAddress immutable, so a mismatch would revert; refusing it at validation means Silhouette never signs a permit whose settlement cannot complete.

If any check fails, Silhouette refuses to sign and the quote is not selected. It ends NOT_SELECTED if a conforming quote wins the auction, or EXPIRED if the auction closes with no winner.

The one-unit rounding tolerance

xStocks store balances as shares, and a non-unity management-fee multiplier can round a nominal transfer down by one base unit when converting shares back to balanceOf. A wrapper therefore pins, at deployment, the exact xStock addresses for which it accepts a delivery one base unit short of the nominal amount. The tolerance never permits zero delivery.

Stablecoins and other ordinary tokens are never in that set: any shortfall for them, a larger xStock shortfall, or a delivery to the omnibus above what it is owed reverts. A surplus you keep in xChange mode is not such a delivery — it stays in your wrapper and never reaches the omnibus. Silhouette applies the same token-specific tolerance when it reconciles the receipt, reading the policy from the winning wrapper, so the tolerance enforced on-chain and the tolerance applied at reconciliation cannot diverge, and reconciliation is not weakened for ordinary tokens.

The tolerance has a routing consequence in xChange mode. Routing a pinned token through your wrapper means two share conversions rather than one, so Backed must deliver more than the quoted amount: without a surplus the wrapper could not forward the nominal amount it owes the omnibus, and the call reverts with RoundingTokenRequiresSurplus. Deliver a pinned token straight to the omnibus whenever you are not taking your spread on that leg. The Backed xStock deployment is rebasing, not fee-on-transfer.

Settlement confirmation

Silhouette observes the Filled event, requires the payer to equal the configured omnibus and the emitting contract to equal the wrapper captured on the winning quote, then derives the omnibus's net taker-token outflow and maker-token inflow from the transaction receipt (rather than trusting the Filled amounts) and only then credits the fill. The taker-token outflow must equal the quote; the maker-token inflow must too, subject to the one-unit tolerance above. The quote then reads SETTLED.

Deploying

The wrappers target Solidity ^0.8.26. Everything the wrapper trusts is a constructor immutable and cannot change after deployment: a new maker identity, omnibus, Permit2 address, AtomicSwap address, Backed transfer address, or one-unit-rounding token set means a fresh deployment.

fill() is deliberately permissionless on the inventory wrapper. Its safety comes from the Permit2 signature and the pinned omnibus, not from a caller allowlist. You may extend a reference wrapper, with metrics or a submitter allowlist, provided the Filled event shape stays byte-for-byte identical, the legs stay atomic, and the inventory wrapper retains its immutable omnibus and maker checks. The authorization, reentrancy, counterparty, allowance, and balance-delta requirements remain mandatory.

Compile with evm_version = "paris". HyperEVM does not support PUSH0, so a post-Shanghai target produces bytecode that cannot run. Verify the deployed bytecode on the block explorer.

Review is mandatory and gating: Silhouette reviews the wrapper source before mainnet deployment and pins the approved deployed code hash, so a wrapper with no approved pin is refused, and repointing a registered wrapper at unapproved code cannot bypass the check. A wrapper settles only once its source is approved and its deployed bytecode confirmed to match, whether you deploy a reference implementation unchanged or write your own. Silhouette shares reference implementations of both wrappers, the Permit2 and Backed interfaces, and a deployment script during onboarding.