PizzaSwap - Fractal Swap Service

This document defines a Swap Module for brc-20.

Under the brc-20 Module, we introduce a mechanism that enables users to swap their balances within the module in a seamless and secure way.

All swap-related operations can be seen as a list, updated only by the module’s sequencer, and each event includes the signature of the initiating user.

This ensures maximum security and order throughout the swap process.This proposal defines two operations within the brc-20 module: commit and approve.

Swap Mechanism

Each module instance can include multiple swap pairs, and any user can publicly create a swap pair.

Each module operates independently, with the module’s sequencer responsible for aggregating (committing) all user activities internally and submitting them to the chain.

To prevent users from disrupting aggregation operations with withdrawals, balances in the module are split into two categories: fund balance and trade balance. Deposits into the module are credited to the trade balance, and only the trade balance can participate in aggregation operations.

Only the fund balance can be withdrawn. Users can use the approve operation to transfer the fund balance to the trade balance, or use the decreaseApproval function to convert the trade balance back into the fund balance. The approve inscription can be used to build a brc-20 market within the module.

When the sequencer sends the aggregated events inscription (commit), users will be charged an aggregation fee based on the number of events. The ticker used to pay the commit fee must be specified in the module initialization parameters, and only the fee ticker from the trade balance can be used to pay the commit fee.

Creating a New Swap Module Instance

In the custom parameter section, the following must be set:

  1. Swap transaction fee rate

  2. Liquidity operation fee rate

  3. The ticker used to pay sequencer fees during aggregation

{
  "p": "brc20-module",
  "op": "deploy",
  "name": "swap",
  "source": "d2a30f6131324e06b1366876c8c089d7ad2a9c2b0ea971c5b0dc6198615bda2ei0", // Refers to the inscription defining the module’s function
  "init": { // Any parameters can be set
    "swap_fee_rate": "0.003", // Swap fee rate, deposited into the pool for liquidity providers after collection
    "gas_tick": "sats", // Token used to pay gas fees during aggregation (must be a BRC-20 token)
    "gas_to": "bc1q...", // Address receiving the gas fee (must be specified)
    "fee_to": "bc1q...", // Address receiving the liquidity provider fee (must be specified)
    "sequencer": "bc1q..." // Address of the sequencer (must be specified)
  }
}

Withdrawing brc-20 Ticker from the Module to a Target Address (Withdraw)

  • The user must first inscribe a Withdraw inscription, then transfer it to the target address for it to take effect. The inscription can only be used once.

  • The brc-20 tickers under the user’s account in the module will be withdrawn to the target address.

  • The inscription must include the module’s ID. If the module doesn’t exist, the withdraw is invalid.

  • The module must be a white module for withdrawals to be supported. The withdraw inscription does not lock the withdrawable balance when minted, but requires sufficient balance at the time of sending the withdraw transaction. Withdraw inscriptions sent back to the swap module will result in the destruction of the balance.

{
  "p": "brc20-module",
  "op": "withdraw",
  "tick": "ordi",
  "amt": "10", // Amount of BRC-20 token to withdraw
  "module": "8082283e8f0edfcce4901ff2d271b2eec4a4735b371bb6af53d64770651d8c14i0" // Module from which to withdraw
}

Approving brc-20 Ticker Balances in the Module to a Target Address (Approve)

  • The user must first inscribe an Approve inscription, then transfer it to the target address for it to take effect. The inscription can only be used once.

  • The balance will be assigned to the authorized balance of the specified address in the module.

  • The inscription must include the module’s ID. If the module doesn’t exist, the approve inscription is invalid.

  • Only approved balances can be used in the commit event function. For safety, sequencers may reject user requests for approved balances that have fewer than 60 confirmations.

{
  "p": "brc20-swap",
  "op": "approve",
  "tick": "ordi",
  "amt": "10", // Amount of BRC-20 token to approve
  "module": "8082283e8f0edfcce4901ff2d271b2eec4a4735b371bb6af53d64770651d8c14i0" // Approve to the swap module
}

Event Aggregation in Swap

All events that occur in the swap pool are described by functions. To ensure the orderliness of events, these functions are not inscribed individually but are placed in the module's aggregated inscription (commit).The basic rules are as follows:

  • During aggregation, each operation must be signed by the user’s address for all previous content (serialization including module and parent).

  • The aggregated inscription must be inscribed by the module sequencer and transferred to the module ID address to take effect. The sequencer is the address specified in the module inscription. The sequencer address can be updated using the update inscription.

  • For each function aggregated by the module sequencer, a fixed aggregation fee must be charged to the user. The user must deposit and approve sufficient fee tokens in the module, or else the sequencer must reject the user’s event request. If the user has insufficient fees, the commit inscription will be invalid.

To avoid blocking when sending large numbers of commit inscriptions due to the mempool limit of 100KB and the 25-child transaction limit, commit inscriptions can initially be minted to the sequencer using unrelated UTXOs, regardless of the order. The sequencer can then periodically transfer commits in batches to the module address, ensuring that all previous logic is chained together and valid. This process significantly reduces the number of commit inscriptions that need to be transferred, minimizing the impact of the 25-child limit on the commit process.

Aggregated Inscription (Commit)

{
  "p":"brc20-swap",
  "op": "commit",
  "module": "8082283e8f0edfcce4901ff2d271b2eec4a4735b371bb6af53d64770651d8c14i0",  // Module ID
  "parent": "xxxxi0", // Which commit inscription state to follow; leave blank if this is the first commit inscription
  "gas_price": "100", // Fee charged per function
  "data": [
    { 
      "addr": "bc1q...", // Can be any address        
      "func": "deployPool",
      "params": [
        "ordi", // Token0
        "pepe"  // Token1
      ],
      "ts": 12345,
      
      "sig": "xxx"
    },
    { 
      "addr": "bc1q...", 
      "func": "addLiq",
      "params": [
        "ordi/pepe",  // Swap pool name
        "100",  // Amount of Token0 the user is injecting
        "200",  // Amount of Token1 the user is injecting
        
        "100",  // Expected LP tokens to receive
        "0.005"  // Slippage
      ],
      "ts": 12345,
      
      "sig": "xxxx"
    },
    { 
      "addr": "bc1q...",         
      "func": "swap",
      "params": [
        "ordi/pepe",   // Swap pool name  
        "ordi",        // Token the user is swapping
        "200",         // Amount of token the user is swapping
        "exactIn",     // Exact input or output (exactIn/exactOut)
        
        "12.324",      // Expected amount of the other token (exactIn) or provided amount (exactOut)
        "0.005"        // Slippage
      ],
      "ts": 12345,
      
      "sig": "xxxx"
    }
  ]
}

Aggregated Inscription Structure Validity Details

  1. Inscription field keys must be lowercase characters, and except for tick, values are case-sensitive.

  2. Duplicate fields are allowed, and extra fields are allowed.

User Signature Authorization

Each function in the aggregated inscription must include the user’s address and signature to authorize the sequencer to operate. Initially, only taproot address formats are supported, with other formats such as p2wpkh, p2wsh, p2sh, p2pkh, and hex-encoded arbitrary locking scripts to be supported later.The signature follows the BIP-322 format to sign the following content:

id: idxxxx
addr: bc1q...
func: addLiq
params: ordi/pepe 100 200 12.324
ts: 12345

Each function has a function hashid, calculated as the SHA-256 hash of the serialized message (msg) with the following content:

module: 8082283e8f0edfcce4901ff2d271b2eec4a4735b371bb6af53d64770651d8c14i0
parent: idxxxxi0
gas_price: 100
prevs: id1 id2 id3
addr: bc1q...
func: addLiq
params: ordi pepe 100 200 12.324
ts: 12345

Note: List the data for each field in the specified order, with each field ending in a newline character (\n).

Parameters in the params field are separated by spaces. If a field value is empty, omit that line in the serialized data.

The function ID is a fully computable intermediate result and it may be omitted from subsequent new inscriptions after testing is completed, meaning it does not need to be recorded again. However, it cannot be removed from inscriptions that have already been issued. The prevs field contains all function IDs submitted by the same user in the current inscription, sorted in the order in which the sequencer accepts them.

In the entire commit inscription, only the last signature for each address needs to be on-chain, as the intermediate function data has been overwritten by the last signature.

Swap Functions Available

DeployPool

  • Deploy a liquidity swap pool, specifying token0 and token1 as the two tokens participating in the swap pool. token0 and token1 must be different.

  • Only one liquidity pool is allowed to be deployed for the same pair of tokens within a module. There is no order relationship between the two tokens in the liquidity pool.

  • The liquidity ticker name will be "token0/token1". It is not possible to use liquidity tokens to deploy another liquidity pool.

  • Liquidity assets are always confined within the module they were deployed in and cannot be withdrawn. Each pool tracks the distribution of liquidity assets among holders.

  • Anyone can deploy a liquidity pool, but the deployer has no special permissions.

{
  "func": "deployPool",
  "params": [
    "ordi", // Token0
    "pepe"  // Token1
  ],
  "addr": "bc1q...",  // Can be any address  
  "ts": 12345,
  "sig": "xxx"
}

AddLiquidity

Users add liquidity, operating only on approved module balances.

{
  "func": "addLiq",
  "params": [
    "ordi",
    "pepe",      // Swap pool name (e.g., pepe/ordi); subsequent parameters correspond to this token order
    "100",       // Amount of ordi to inject
    "200",       // Amount of pepe to inject
    
    "100",       // Expected LP tokens to receive
    "0.005"      // Slippage
  ],
  "addr": "bc1q...",
  "ts": 12345,        
  "sig": "xxx"
}

RemoveLiquidity

Removes liquidity, with the tickers received belonging to the approved balance.

{
  "func": "removeLiq",
  "params": [
    "ordi",
    "pepe",      // Swap pool name (e.g., pepe/ordi); subsequent parameters correspond to this token order
    "100",       // Amount of LP tokens to remove
    
    "12.324",    // Expected amount of ordi to receive
    "321.3",     // Expected amount of pepe to receive
    "0.005"      // Slippage
  ],
  "addr": "bc1q...",   
  "ts": 12345,
  "sig": "xxxx"
}

Swap

Executes a swap transaction, using only approved balances, with the received tickers belonging to the approved balance.

{
  "func": "swap",
  "params": [
    "ordi",
    "pepe",         // Swap pool name (ordi/pepe)
    "ordi",         // Token the user is swapping
    "200",          // Amount of token the user is swapping
    "exactIn",      // Exact input or output (exactIn/exactOut)
    
    "12.324",       // Expected amount to receive (exactIn) or provide (exactOut)
    "0.005"         // Slippage
  ],
  "addr": "bc1q...",   
  "ts": 12345,
  "sig": "xxxx"
}

DecreaseApproval

Reduces the authorized balance of a ticker, increasing the withdrawable balance.

{
  "func": "decreaseApproval",
  "params": [
    "ordi",   // Token to adjust
    "10"      // Amount to adjust by
  ],
  "addr": "bc1q...", 
  "ts": 12345,   
  "sig": "xxx"
}

Send

Sends balances of various tickers within the module. Only authorized balances can be sent. The recipient’s balance will also be authorized. Liquidity tokens cannot be sent.

{
  "func": "send",
  "params": [
    "bc1q...",     // Recipient address
    "ordi",        // Token being sent
    "10"           // Amount of token being sent
  ],
  "addr": "bc1q...", 
  "ts": 12345,   
  "sig": "xxx"
}

SendLp

Sends liquidity tickers within the module.

{
  "func": "send",
  "params": [
    "bc1q...",     // Recipient address
    "ordi",        // Token0 of the liquidity pair
    "pepe",        // Token1 of the liquidity pair
    "10"           // Amount of tokens being sent
  ],
  "addr": "bc1q...", 
  "ts": 12345,   
  "sig": "xxx"
}

Function declaration

class SwapContract{
    function deployPool(params:{ token0: string; token1: string }){
        // todo
    }
    
    function addLiq(params: {
        token0: string;
        token1: string;
        amount0: string;
        amount1: string;
        address: string;
    }){
        // todo
    }
    
    function removeLiq(params:{liqAmount:string}){
        // todo
    }
    
    function swap(params:{
        token0: string;
        token1: string;
        token: string;
        amount: string;
        address: string;
    }){
        // todo
    }
    
    function withdraw(params:{token:string; amount:string}){
        // todo
    }
    
    function send(params:{address:string; token:string; amount:string}){
        // todo
    }    
    
    function deployStake(params:{type:string; tokenLp:string; tokenEarn:string; erate:string; dmax:string}){
        // todo
    }
    
    function stake(params:{tokenLp:string; token:string; amount:string}){
        // todo
    }
    
    function unstake(params:{tokenLp:string; token:string; amount:string}){
        // todo
    }        
}

Swap Fees

Every swap incurs a 0.3% transaction fee. 1/6 of this fee goes to the platform, while the rest is distributed among liquidity providers (LPs).

Note: For example, if a user swaps 1000 ORDI for SATS, 1000 ORDI will be deposited into the liquidity pool. A fee of 3 ORDI is deducted, so the actual amount of SATS the user receives is calculated based on 997 ORDI.

Network Fees

The sequencer is responsible for gathering each aggregation function and minting the aggregated inscription on-chain, incurring network fees for miners to package the transaction. We calculate the network fee charged to users based on the current network fee rate and the user’s aggregation operations:

The formula: fee=gasPricefee = gasPrice, where:

  • gasPrice: Network fee charged per function, depending on two factors: (a) the BTC network rate and (b)the market price of the ticker being charged:

gasPrice=a/bgasPrice = a / b

  • fee: Measured in the ticker being charged. fee: Measured in the Tick being charged.

Optimization of Function Bytes in Commit Inscription

  1. Remove redundant function ID (saves 70 bytes)

  2. Only keep the last signature for the same address (saves 85 bytes)

  3. Number the address in the commit inscription and use the reference number in subsequent functions (saves 40 bytes)

  4. Reduce unnecessary precision in function parameter values, especially those far beyond slippage impact (saves 10 bytes)

The average size of a standard operation is 330 bytes. The theoretical maximum bytes that can be saved is 200 bytes.

Calculation Model

Using the existing Uniswap v2 model, we can define four operations for the pool.Given a swap pool with two tokens, token A and token B, the following notation is used:

  • A: The balance of token A in the pool.

  • B: The balance of token B in the pool.

  • C: The balance of liquidity tokens in the pool.

The constant product formula is:AB=kA \cdot B = k, C=AB=kC = \sqrt{A \cdot B} = \sqrt{k}

Four operations are as follows:

  1. Swap token A for token B: b=aBa+Ab = \frac{a \cdot B}{a + A}

  2. Swap token B for token A: a=bAb+Ba= \frac{b \cdot A}{b + B}

  3. Add liquidity by adding token A and token B to receive liquidity token C:

Use amount a and b to swap amount c, taking the smaller of the two: c=aACc = \frac{a}{A}\cdot C, c=bBCc = \frac{b}{B} \cdot C

  1. Remove liquidity by consuming liquidity tokens to receive token A and token B:

Use amount c to swap amounts a and b: a=cCAa= \frac{c}{C} \cdot A , b=cCBb = \frac{c}{C} \cdot B

The fee is deducted from the user’s tickers upfront.

During swap calculations, high-precision libraries are used to avoid precision loss during addition and multiplication. The final division will maintain the correct decimal precision for the ticker.

The user’s received amount will be the result of accurate calculations with truncated decimals. Manipulating precision for profit is not possible.

If the result is zero after truncation, the operation is not allowed, as the amount is too low.

Slippage

The slippage mechanism is similar to Uniswap, allowing users to tolerate a margin of error in the expected result, considering the following:

  1. Reduced Failure Rate: Front-running swap operations can fail due to concurrency. If a user’s signature takes 1 second and another user confirms their signature during that time, the transaction may fail. The longer the signature process, the higher the likelihood of failure.

  2. Lower Backend Pressure: Front-running swaps require frequent updates to swap data, increasing the backend query load.

  3. Uniswap-Consistent Model: Using the same model as Uniswap lowers the learning curve for users and increases adoption.

Summary of Swap Formulas

Slippage:

  • exactIn: 1/(1+slippage)quoteAmount1/(1+slippage) \cdot quoteAmount

  • exactOut: (1+slippage)quoteAmount(1+slippage) \cdot quoteAmount

Swap:

  • amountIn: Input ticker amount

  • amountOut: Output ticker amount

  • reserveIn: Input ticker reserves in the pool

  • reserveOut: Output ticker reserves in the pool

  • Assume a 0.3% fee

exactIn

  • amountInWithFee=amountIn997amountInWithFee = amountIn \cdot 997

  • amountOut=amountInWithFeereserveOutreverseIn1000+amountInWithFeeamountOut = \frac{amountInWithFee \cdot reserveOut }{reverseIn \cdot 1000 + amountInWithFee}

exactOut

  • amountIn = reserveIn  amountOut  1000 (reserveOutamountOut)  997  + 1amountIn = \frac{reserveIn \cdot amountOut \cdot 1000 }{(reserveOut - amountOut) \cdot 997}  + 1

AddLiquidity:

  • amount0: Amount of token0 added

  • amount1: Amount of token1 added

  • poolLp: LP token supply in the pool

  • reserve0: Token0 reserves in the pool

  • reserve1: Token1 reserves in the pool

  • For the initial liquidity addition: lp=amount0amount11000lp = \sqrt{amount0 \cdot amount1} - 1000

  • For subsequent liquidity additions: lp=min(amount0poolLpreserve0,amount1poolLpreserve1)lp = \min(\frac{amount0 \cdot poolLp}{reserve0}, \frac{amount1 \cdot poolLp}{reserve1} )

RemoveLiquidity:

  • Amount of token0 received: amount0=lpreserve0poolLpamount0 = \frac{lp \cdot reserve0}{poolLp}

  • Amount of token1 received: amount1=lpreserve1poolLpamount1 = \frac{lp \cdot reserve1}{poolLp}

Platform Fee Calculation

  • If the platform takes 1/6 of the fees, we need to calculate how much LP should be minted by the platform to capture exactly 1/6 of the incremental wealth.

  • Minting Timing: Before each liquidity addition/removal, the previously accumulated LP to be minted should be settled.

  • Assuming the platform collects 1/6 of the fees, it needs to calculate how much additional LP should be minted so that the platform's share is exactly 1/6 of the increment.

  • Timing of execution: Before each liquidity addition or removal, settle the cumulative LP amount that should be minted.

  • poolLp: LP token supply in the pool

  • reserve0: Token0 reserves in the pool

  • reserve1: Token1 reserves in the pool

  • kLast: The k value calculated after the last liquidity event

  • The square root of the current assets in the pool: rootK=reserve0reserve1rootK = \sqrt{reserve0 \cdot reserve1}

  • The square root of the last liquidity event: rootKLast=kLastrootKLast = \sqrt{kLast}

  • Platform lp amount: lp=poolLp(rootKrootKLast)rootK5+rootKLastlp = \frac{poolLp \cdot (rootK - rootKLast)}{rootK \cdot 5 + rootKLast }

The derivation process is shown below:

As shown above, to achieve 1/6 of the new wealth, the minted LP should satisfy the following equation: lplpSupply=/6(5/6)+rootKLast\frac{lp}{lpSupply} = \frac{∆/6}{(∆5/6) + rootKLast }, where ∆ = rootK - rootKLast

FAQ

Can anyone deploy a swap module?

  • Yes, anyone can deploy a swap module, but managing it requires some complexity:

    • You need to provide an API for users to query the current pool and submit new transactions.

    • You must regularly mint inscriptions to submit transactions on-chain.

  • UniSat will parse all swap transactions on-chain.

  • app-swap.unisat.io is a product of UniSat, where UniSat has deployed a series of trading pair pools for users to participate in swaps.

Can the contract source ID referenced during deployment be changed?

  • Currently, it cannot be changed. The plugin author specifies the current contract code.

If there’s a bug in the contract, can it be upgraded?

  • Yes. If a version of the contract has a bug, a new version can be deployed, and liquidity can be transferred to the new version.

Last updated