Skip to main content
Every user gets their own VaultImplementation clone (EIP-1167 minimal proxy) deployed by VaultFactory on first deposit.

VaultFactory

function deployVault(address user) external returns (address vault);
function vaultOf(address user) external view returns (address);
  • deployVault is idempotent — calling it for a wallet that already has a vault is a no-op that returns the existing clone address.
  • At creation the clone is initialised with vaultOwner = user and auto-approvals to CTFExchange, NegRiskAdapter, PredictStreetNegRiskCtfExchange, ConditionalTokens.
  • deployVault consumes ~4M gas for the very first clone (storage initialisation of the auto-approvals). Send a gasLimit of 5M to avoid out of gas reverts. Subsequent re-calls are no-ops and cheap.

Bootstrap: vault → deposit

// 1. Make sure vault exists
await (await vaultFactory.deployVault(eoa, { gasLimit: 5_000_000 })).wait();
const vault = await vaultFactory.vaultOf(eoa);

// 2. Approve USDC and deposit into the vault
await (await usdc.connect(user).approve(vault, parseUnits('1000', 6))).wait();
await (await new Contract(
  vault,
  ['function depositERC20(address token, uint256 amount)'],
  user,
).depositERC20(usdc.target, parseUnits('1000', 6))).wait();
Per-EOA deposit limits in DepositLimitRegistry are initialised by the platform automatically in response to the VaultCreated event — partners do not call initializeLimits themselves. If you fire depositERC20 in the same block as deployVault, the deposit can briefly revert with selector 0x87138d5c = NotInitialized(); wait one block and retry.

VaultImplementation entry points

All mutating calls require two EIP-712 signatures (vault owner + factory owner). msg.sender is unconstrained.
FunctionPurpose
withdrawERC20(token, to, amount, salt, deadline, ownerSig, backendSig)External withdrawal
withdrawERC1155(token, to, id, amount, salt, deadline, ownerSig, backendSig)Withdraw outcome tokens
splitPosition(kind, collateral, conditionId, partition, amount, salt, deadline, ownerSig, backendSig)Mint full outcome set inside the vault
mergePositions(...)Collapse outcome set → collateral
convertPositions(marketId, indexSet, amount, salt, deadline, ownerSig, backendSig)Neg-risk NO-basket conversion. The negRiskAdapter reference is held as a constructor-set immutable on the vault clone, so it is not part of the signed struct — only the seven typed fields above.

Splitting USDC into YES + NO inside the vault

SELL orders need the outcome ERC-1155 to live in the vault (the on-chain _verifyVault check looks up the position by maker = vault). The supported flow is:
  1. Backend co-signature. POST /api/vault/split-signature with { marketId, amount }. Body of response carries the dual-sig payload the vault expects:
    {
      "pendingSplitId":  "...",
      "kind":            0,
      "collateralToken": "0x9bC8...241e1",
      "conditionId":     "0x...",
      "partition":       ["1", "2"],
      "amount":          "30000000",
      "salt":            "...",
      "deadline":        1776999999,
      "vaultAddress":    "0x...",
      "backendSig":      "0x..."
    }
    
  2. Owner signature. Sign the same SplitPosition typed-data with the vault EIP-712 domain (see below) using the EOA key.
  3. On-chain submit. Call vault.splitPosition(kind, collateral, conditionId, partition, amount, salt, deadline, ownerSig, backendSig).
After the tx confirms, the vault holds equal balances of YES and NO ERC-1155 for conditionId, and SELL orders on either outcome become possible.

Merging YES + NO back into USDC

mergePositions is the inverse of splitPosition: it burns equal units of every outcome ERC-1155 inside the vault and returns the matching USDC. Useful for closing a hedged position before resolution, freeing locked outcome tokens back into spendable USDC, or just exiting a market early without waiting for the oracle. The flow mirrors split:
  1. Backend co-signature. POST /api/vault/merge-signature with { marketId, amount }. Response shape is identical to split’s, only the field name changes (pendingMergeId instead of pendingSplitId):
    {
      "pendingMergeId":  "...",
      "kind":            0,
      "collateralToken": "0x9bC8...241e1",
      "conditionId":     "0x...",
      "partition":       ["1", "2"],
      "amount":          "30000000",
      "salt":            "...",
      "deadline":        1776999999,
      "vaultAddress":    "0x...",
      "backendSig":      "0x..."
    }
    
  2. Owner signature. Sign the same MergePositions typed-data with the vault EIP-712 domain. The struct field shape is identical to SplitPosition (same 7 fields); only the EIP-712 primary type changes from SplitPositionMergePositions. Use type table below.
  3. On-chain submit. Call vault.mergePositions(kind, collateral, conditionId, partition, amount, salt, deadline, ownerSig, backendSig).
After the tx confirms, the vault burns amount of each outcome’s ERC-1155 and credits amount USDC; the platform reflects the balance change with ~1-block lag and your /api/me/balances shows available += amount. Both SplitPosition and MergePositions use the same EIP-712 type shape on chain:
{
  "<SplitPosition or MergePositions>": [
    { "name": "kind",            "type": "uint8"     },
    { "name": "collateralToken", "type": "address"   },
    { "name": "conditionId",     "type": "bytes32"   },
    { "name": "partition",       "type": "uint256[]" },
    { "name": "amount",          "type": "uint256"   },
    { "name": "salt",            "type": "uint256"   },
    { "name": "deadline",        "type": "uint256"   }
  ]
}
Binary-market only in MVP (outcomes == 2, partition [1, 2]). Neg-risk N-outcome merges land on the NegRiskAdapter path later.

Emergency withdraw

initiateEmergencyWithdraw(ownerSig)       // starts 7-day timelock
// ... wait 7 days ...
emergencyWithdrawERC20(token, to, amount, ownerSig)
emergencyWithdrawERC1155(token, to, id, amount, ownerSig)
Backend can abort:
cancelEmergencyWithdraw()

Digest invalidation

invalidateDigest(bytes32 digest) external onlyVaultOwner;
invalidateDigests(bytes32[] calldata digests) external onlyVaultOwner;

Why EIP-1167 minimal proxy

  • Gas-cheap deployment (~45k gas vs ~2M for a full copy).
  • Non-upgradeable — implementation pinned, admin can’t hot-patch.
  • Bug mitigation requires new factory + user-driven emergency withdraw.

EIP-712 domain

name: "PredictStreetVault"
version: "1"
chainId: <chain>
verifyingContract: <your vault clone address>
Note verifyingContract is the vault address, not the factory.