Base Bets (MBC Hackathon)
A prediction-powered USDC escrow system that automatically releases funds based on real-world outcomes verified through Polymarket, built on Base L2 with gasless transactions.
Building trustless conditional payments using prediction markets as decentralized oracles
Introduction
What if you could make a bet with a friend—”I’ll pay you $50 if Bitcoin hits $100k by year-end”—and have it settle automatically, with no trust required?
That’s what my team built at the Midwest Blockchain Conference hackathon. Base Bets is a prediction-powered escrow system where USDC funds are locked in a smart contract and automatically released based on real-world outcomes verified through Polymarket.
In this write-up, I’ll explain the architecture, walk through the smart contract design, and share what I learned about building financial infrastructure on blockchain.
What We Built
A complete escrow platform featuring:
- ✅ Solidity smart contracts deployed on Base L2
- ✅ Automatic settlement via Polymarket prediction markets
- ✅ Gasless transactions through ERC-4337 account abstraction
- ✅ USDC stablecoin integration (real dollar-value escrows)
- ✅ Emergency refund mechanism for unresolved markets
Part 1: The Problem
Traditional Escrow is Broken
When two parties want to make a conditional agreement—”I’ll pay you X if Y happens”—they have three bad options:
- Trust each other → One party can refuse to pay
- Use a lawyer → Expensive, slow, overkill for small amounts
- Use an escrow service → Still requires trusting a third party
The fundamental issue: someone has to decide when the condition is met and authorize the release of funds.
The Oracle Problem
Smart contracts can hold and transfer funds automatically, but they can’t observe the real world. How does the contract know if Bitcoin hit $100k? If the Lakers won? If a bill passed Congress?
This is the “oracle problem:” bringing real-world data on-chain in a trustworthy way.
Our Solution: Prediction Markets as Oracles
Polymarket is a prediction market where traders buy and sell shares on real-world outcomes. If you think Bitcoin will hit $100k, you buy YES shares. The market price reflects the crowd’s probability estimate.
When the event happens, the market resolves: YES shares become worth $1, NO shares become worth $0. This resolution is verified by Polymarket’s Gamma API.
We use this resolution as our oracle. Instead of trusting a single arbiter, we trust the economic incentives of a prediction market with millions of dollars at stake.
Part 2: Smart Contract Architecture
The Escrow Data Structure
Each escrow agreement stores everything needed for automatic settlement:
struct Escrow {
address depositor; // Who created the escrow
address beneficiary; // Who receives USDC if depositor is wrong
uint256 amountA; // Amount staked by depositor (6 decimals)
uint256 amountB; // Amount staked by beneficiary (6 decimals)
string marketId; // Polymarket condition ID
bool expectedOutcomeYes; // If true, depositor wins when YES wins
Status status; // Active, Resolved, or Refunded
uint256 createdAt; // Block timestamp when created
bool beneficiaryAccepted; // Whether beneficiary has locked their funds
}
This is a two-sided escrow where both parties stake funds, and the winner takes all. The marketId links to a specific Polymarket question, and expectedOutcomeYes specifies which outcome the depositor is betting on.
Creating an Escrow
When a depositor creates an escrow, they stake their USDC and specify how much the beneficiary must stake to accept:
function createEscrow(
address beneficiary,
uint256 amountA,
uint256 amountB,
string calldata marketId,
bool expectedOutcomeYes
) external nonReentrant returns (uint256 escrowId) {
// Validation
if (beneficiary == address(0)) revert InvalidAddress();
if (beneficiary == msg.sender) revert CannotEscrowToSelf();
if (amountA == 0 || amountB == 0) revert InvalidAmount();
if (bytes(marketId).length == 0) revert InvalidMarketId();
// Transfer USDC from depositor to this contract
usdc.safeTransferFrom(msg.sender, address(this), amountA);
// Create escrow
escrowId = escrowCount++;
escrows[escrowId] = Escrow({
depositor: msg.sender,
beneficiary: beneficiary,
amountA: amountA,
amountB: amountB,
marketId: marketId,
expectedOutcomeYes: expectedOutcomeYes,
status: Status.Active,
createdAt: block.timestamp,
beneficiaryAccepted: false
});
// Update stats
userStats[msg.sender].escrowsCreated++;
emit EscrowCreated(escrowId, msg.sender, beneficiary, amountA, marketId, expectedOutcomeYes);
}
At this point, the escrow is pending and the beneficiary must accept before it’s active.
Accepting an Escrow
The beneficiary reviews the terms and stakes their side:
function acceptEscrow(uint256 escrowId) external nonReentrant {
Escrow storage escrow = escrows[escrowId];
if (escrow.amountA == 0) revert EscrowDoesNotExist();
if (escrow.status != Status.Active) revert EscrowNotActive();
if (msg.sender != escrow.beneficiary) revert NotAuthorized();
if (escrow.beneficiaryAccepted) revert("Escrow already accepted");
// Transfer USDC from beneficiary to this contract
usdc.safeTransferFrom(msg.sender, address(this), escrow.amountB);
// Mark as accepted
escrow.beneficiaryAccepted = true;
}
Now both parties have skin in the game. The contract holds amountA + amountB, and the winner takes all.
Key security patterns:
-
nonReentrant: Prevents reentrancy attacks during token transfers -
safeTransferFrom: OpenZeppelin’s safe ERC-20 transfer that reverts on failure - Custom errors: Gas-efficient reverts with descriptive messages
Resolution: Winner Takes All
When the Polymarket market resolves, our off-chain resolver service detects it and calls the contract:
function resolveEscrow(
uint256 escrowId,
bool marketResolvedYes
) external nonReentrant {
if (msg.sender != resolver) revert OnlyResolver();
Escrow storage escrow = escrows[escrowId];
if (escrow.amountA == 0) revert EscrowDoesNotExist();
if (escrow.status != Status.Active) revert EscrowNotActive();
if (!escrow.beneficiaryAccepted) revert("Escrow not yet accepted");
// Update status
escrow.status = Status.Resolved;
// Total payout to winner
uint256 totalAmount = escrow.amountA + escrow.amountB;
// Determine recipient and update stats
address recipient;
if (marketResolvedYes == escrow.expectedOutcomeYes) {
// Depositor wins (they predicted correctly)
recipient = escrow.depositor;
userStats[escrow.depositor].totalWon += totalAmount;
userStats[escrow.depositor].escrowsWon++;
userStats[escrow.beneficiary].totalLost += totalAmount;
userStats[escrow.beneficiary].escrowsLost++;
} else {
// Beneficiary wins (depositor's prediction was wrong)
recipient = escrow.beneficiary;
userStats[escrow.beneficiary].totalWon += totalAmount;
userStats[escrow.beneficiary].escrowsWon++;
userStats[escrow.depositor].totalLost += totalAmount;
userStats[escrow.depositor].escrowsLost++;
}
// Transfer all funds to winner
usdc.safeTransfer(recipient, totalAmount);
emit EscrowResolved(escrowId, recipient, totalAmount, marketResolvedYes);
}
The logic:
- Depositor bets that
expectedOutcomeYeswill match the market result - If the market resolves the way depositor predicted → depositor wins both stakes
- If the market resolves the opposite way → beneficiary wins both stakes
We also track statistics (totalWon, escrowsWon, etc.) for leaderboards and user profiles.
Batch Resolution
For gas efficiency when multiple markets resolve simultaneously:
function resolveEscrowBatch(
uint256[] calldata escrowIds,
bool[] calldata outcomes
) external nonReentrant {
if (msg.sender != resolver) revert OnlyResolver();
require(escrowIds.length == outcomes.length, "Array length mismatch");
for (uint256 i = 0; i < escrowIds.length; i++) {
// Skip invalid/inactive escrows instead of reverting
Escrow storage escrow = escrows[escrowIds[i]];
if (escrow.amountA == 0 || escrow.status != Status.Active || !escrow.beneficiaryAccepted) {
continue;
}
// ... resolution logic for each escrow
}
}
This saves gas by amortizing the base transaction cost across multiple resolutions.
Emergency Refund: The Safety Valve
What if a Polymarket market never resolves? We can’t let funds be locked forever:
function emergencyRefund(uint256 escrowId) external nonReentrant {
Escrow storage escrow = escrows[escrowId];
if (escrow.amountA == 0) revert EscrowDoesNotExist();
if (escrow.status != Status.Active) revert EscrowNotActive();
if (block.timestamp < escrow.createdAt + ESCROW_TIMEOUT) revert TimeoutNotReached();
if (msg.sender != escrow.depositor && msg.sender != owner()) revert NotAuthorized();
escrow.status = Status.Refunded;
// Refund depositor's amount
usdc.safeTransfer(escrow.depositor, escrow.amountA);
// If beneficiary accepted and deposited, refund their amount too
if (escrow.beneficiaryAccepted) {
usdc.safeTransfer(escrow.beneficiary, escrow.amountB);
}
emit EscrowRefunded(escrowId, escrow.depositor, escrow.amountA, "timeout");
}
After 7 days (ESCROW_TIMEOUT), either party can reclaim their funds if the market hasn’t resolved. Both sides get their original stakes back — it’s a no-contest refund, not a winner declaration.
Part 3: The Resolver Service
The smart contract can’t query Polymarket directly, it needs an off-chain service to bridge the data.
Architecture
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Polymarket │ │ Resolver │ │ Smart Contract │
│ Gamma API │────▶│ Service │────▶│ (Base L2) │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │ │
Market data Polls every Calls resolve()
& resolution few minutes when market ends
How It Works
- Polling: The resolver queries Polymarket’s Gamma API for market status
- Detection: When a market changes to “resolved”, extract the outcome
- Matching: Find all escrows linked to that marketId
- Settlement: Call
resolveEscrow()for each affected escrow
async function checkAndResolve() {
// Get all pending escrows from contract events
const pendingEscrows = await getPendingEscrows();
for (const escrow of pendingEscrows) {
// Check if market resolved
const market = await polymarket.getMarket(escrow.marketId);
if (market.resolved) {
// Call contract to settle
await contract.resolveEscrow(
escrow.id,
market.outcome === "YES"
);
}
}
}
Trust Assumptions
The resolver is a centralized component so it can call resolveEscrow() with any outcome. We mitigate this through:
- Separation of concerns: Resolver can only trigger resolution, not steal funds
- Verifiability: All resolutions are on-chain and can be audited against Polymarket
- Emergency refund: Users can recover funds if resolver misbehaves
For production, you’d decentralize this using Chainlink Functions or directly integrating with another oracle.
Part 4: Gasless Transactions
The UX Problem
Traditional blockchain apps require users to:
- Buy ETH somewhere
- Transfer ETH to their wallet
- Pay gas fees for every transaction
This is terrible UX. A user wanting to escrow $20 shouldn’t need to understand gas.
ERC-4337: Account Abstraction
ERC-4337 introduces “smart accounts”—wallet contracts with programmable logic. Instead of externally-owned accounts (EOAs) that require ETH for every transaction, smart accounts can have gas paid by someone else.
Our Implementation: Coinbase Smart Wallet + CDP Paymaster
We integrated Coinbase Smart Wallet, which provides ERC-4337 smart accounts with:
- Passkey authentication: Sign transactions with Face ID, no seed phrases
- Gas sponsorship: CDP Paymaster pays transaction fees
- Batching: Multiple operations in one transaction
Traditional Flow:
User EOA → Signs tx → Pays gas in ETH → Contract
Our Flow:
User → Smart Wallet → UserOperation → CDP Paymaster sponsors gas → Contract
The Result
Users can:
- Sign up with just an email
- Deposit USDC (no ETH needed)
- Create escrows (gas sponsored)
- Receive winnings (gas sponsored)
Zero crypto knowledge required. It feels like a normal web app.
Part 5: Why Base L2?
Layer 2 Economics
Ethereum mainnet transactions cost $5-50+ depending on congestion. For a $20 escrow, that’s unacceptable.
Base is an L2 (Layer 2) built on the OP Stack. It batches transactions and posts compressed data to Ethereum, inheriting mainnet security at a fraction of the cost.
| Metric | Ethereum L1 | Base L2 |
|---|---|---|
| Transaction cost | $5-50 | $0.01-0.10 |
| Block time | 12 seconds | 2 seconds |
| Security | Native | Inherited via rollup |
Why This Matters
At $0.05 per transaction, users can create $10 escrows economically. At $20 per transaction, nothing under $500 makes sense.
The 100-1000x cost reduction unlocks an entire category of micro-agreements that were previously impractical.
Part 6: Security Considerations
What We Protected Against
Reentrancy: Using OpenZeppelin’s ReentrancyGuard on all fund-moving functions.
function createEscrow(...) external nonReentrant {
// Safe from reentrancy
}
Integer overflow: Solidity 0.8+ has built-in overflow checks.
Front-running: Not a major concern—escrow creation order doesn’t affect outcomes.
Flash loan attacks: Not applicable—no price oracles or liquidity pools to manipulate.
What We’d Add for Production
- Multi-sig resolver: Require multiple parties to agree on resolution
- Dispute period: Allow challenges before finalization
- Rate limiting: Prevent spam escrow creation
- Formal verification: Mathematically prove contract correctness
Part 7: Use Cases
Peer-to-Peer Bets
“I bet you $50 the Lakers win the Finals.”
Alice creates escrow staking $50, Bob accepts by staking $50. Market resolves. Winner takes $100.
Asymmetric Odds
“I’ll bet $100 against your $20 that Bitcoin hits $150k.”
The depositor can set different stake amounts. If you’re confident, put more skin in the game.
Conditional Business Agreements
“Bonus paid if we hit Q4 revenue targets.”
Link payment to verifiable financial metrics tracked by prediction markets.
Conclusion
Building Base Bets taught me how financial infrastructure actually works on blockchain.
Key takeaways:
- Prediction markets can serve as decentralized oracles for real-world events
- Account abstraction (ERC-4337) enables real consumer UX
- L2s make micro-transactions economically viable
The full source code is available on GitHub.
Team
Built at the Midwest Blockchain Conference Hackathon with:
- Brock Benton — Smart Contract Development
- Tyler Martin — Frontend Development
- Sri Vamsi Andavarapu — Backend/Resolver Integration