feat: request quote endpoint (#67)

This commit is contained in:
Ameen Soleimani
2025-04-25 10:22:11 +03:00
committed by GitHub
31 changed files with 1714 additions and 220 deletions

View File

@@ -34,6 +34,7 @@
},
"dependencies": {
"@0xbow/privacy-pools-core-sdk": "0.1.17",
"@uniswap/v3-sdk": "^3.25.2",
"ajv": "8.17.1",
"body-parser": "1.20.3",
"cors": "^2.8.5",

View File

@@ -12,7 +12,9 @@ export enum ErrorCode {
PROCESSOOOR_MISMATCH = "PROCESSOOOR_MISMATCH",
FEE_RECEIVER_MISMATCH = "FEE_RECEIVER_MISMATCH",
FEE_MISMATCH = "FEE_MISMATCH",
FEE_TOO_LOW = "FEE_TOO_LOW",
CONTEXT_MISMATCH = "CONTEXT_MISMATCH",
RELAYER_COMMITMENT_REJECTED = "RELAYER_COMMITMENT_REJECTED",
INSUFFICIENT_WITHDRAWN_VALUE = "INSUFFICIENT_WITHDRAWN_VALUE",
ASSET_NOT_SUPPORTED = "ASSET_NOT_SUPPORTED",
@@ -31,6 +33,9 @@ export enum ErrorCode {
// SDK error. Wrapper for sdk's native errors
SDK_ERROR = "SDK_ERROR",
// Quote errors
QUOTE_ERROR = "QUOTE_ERROR",
}
/**
@@ -201,6 +206,14 @@ export class WithdrawalValidationError extends RelayerError {
);
}
public static feeTooLow(details: string) {
return new WithdrawalValidationError(
"Fee is lower than required by relayer",
ErrorCode.FEE_TOO_LOW,
details,
);
}
public static feeMismatch(details: string) {
return new WithdrawalValidationError(
"Fee does not match relayer fee",
@@ -209,6 +222,14 @@ export class WithdrawalValidationError extends RelayerError {
);
}
public static relayerCommitmentRejected(details: string) {
return new WithdrawalValidationError(
"Relayer commitment is too old or invalid",
ErrorCode.RELAYER_COMMITMENT_REJECTED,
details,
);
}
public static contextMismatch(details: string) {
return new WithdrawalValidationError(
"Context does not match public signal",
@@ -257,3 +278,16 @@ export class BlockchainError extends RelayerError {
}
}
export class QuoterError extends RelayerError {
constructor(message: string, code: ErrorCode = ErrorCode.QUOTE_ERROR, details?: Record<string, unknown> | string) {
super(message, code, details);
this.name = this.constructor.name;
}
public static assetNotSupported(
details?: Record<string, unknown> | string) {
return new QuoterError("Asset is not supported", ErrorCode.ASSET_NOT_SUPPORTED, details);
}
}

View File

@@ -1,2 +1,3 @@
export * from "./relayer/details.js";
export * from "./relayer/request.js";
export * from "./relayer/quote.js";

View File

@@ -0,0 +1,54 @@
import { NextFunction, Request, Response } from "express";
import { getAddress } from "viem";
import { getAssetConfig, getFeeReceiverAddress } from "../../config/index.js";
import { QuoterError } from "../../exceptions/base.exception.js";
import { web3Provider } from "../../providers/index.js";
import { quoteService } from "../../services/index.js";
import { QuoteMarshall } from "../../types.js";
import { encodeWithdrawalData } from "../../utils.js";
const TIME_20_SECS = 20 * 1000;
export async function relayQuoteHandler(
req: Request,
res: Response,
next: NextFunction,
) {
const chainId = Number(req.body.chainId!);
const amountIn = BigInt(req.body.amount!.toString());
const assetAddress = getAddress(req.body.asset!.toString())
const config = getAssetConfig(chainId, assetAddress);
if (config === undefined)
return next(QuoterError.assetNotSupported(`Asset ${assetAddress} for chain ${chainId} is not supported`));
const feeBPS = await quoteService.quoteFeeBPSNative({
chainId, amountIn, assetAddress, baseFeeBPS: config.fee_bps, value: 0n
});
const recipient = req.body.recipient ? getAddress(req.body.recipient.toString()) : undefined
const quoteResponse = new QuoteMarshall({
baseFeeBPS: config.fee_bps,
feeBPS,
});
if (recipient) {
const feeReceiverAddress = getFeeReceiverAddress(chainId);
const withdrawalData = encodeWithdrawalData({
feeRecipient: getAddress(feeReceiverAddress),
recipient,
relayFeeBPS: feeBPS
})
const expiration = Number(new Date()) + TIME_20_SECS
const relayerCommitment = { withdrawalData, expiration };
const signedRelayerCommitment = await web3Provider.signRelayerCommitment(chainId, relayerCommitment);
quoteResponse.addFeeCommitment({ expiration, withdrawalData, signedRelayerCommitment })
}
res
.status(200)
.json(res.locals.marshalResponse(quoteResponse));
}

View File

@@ -35,6 +35,7 @@ function relayRequestBodyToWithdrawalPayload(
},
withdrawal,
scope,
feeCommitment: body.feeCommitment
};
return wp;
}
@@ -108,6 +109,7 @@ export async function relayRequestHandler(
if (maxGasPrice !== undefined && currentGasPrice > maxGasPrice) {
throw ConfigError.maxGasPrice(`Current gas price ${currentGasPrice} is higher than max price ${maxGasPrice}`)
}
const requestResponse: RelayerResponse =
await privacyPoolRelayer.handleRequest(withdrawalPayload, chainId);

View File

@@ -0,0 +1,10 @@
/**
* Represents the relayer commitment for a pre-built withdrawal.
*/
export interface FeeCommitment {
expiration: number,
withdrawalData: `0x${string}`,
signedRelayerCommitment: `0x${string}`,
}

View File

@@ -0,0 +1,18 @@
import { FeeCommitment } from "./common.js";
export interface QuotetBody {
/** Chain ID to process the request on */
chainId: string | number;
/** Potential balance to withdraw */
amount: string;
/** Asset address */
asset: string;
/** Asset address */
recipient?: string;
}
export interface QuoteResponse {
baseFeeBPS: bigint,
feeBPS: bigint,
feeCommitment?: FeeCommitment
}

View File

@@ -1,7 +1,8 @@
import {
Withdrawal,
Withdrawal as SdkWithdrawal,
WithdrawalProof,
} from "@0xbow/privacy-pools-core-sdk";
import { FeeCommitment } from "./common.js";
/**
* Represents the proof payload for a relayer request.
@@ -34,26 +35,18 @@ export interface WithdrawPublicSignals {
context: bigint;
}
/**
* Represents the payload for a withdrawal relayer request.
*/
export interface WithdrawalRelayerPayload {
/** Relayer address (0xAdDrEsS) */
processooor: string;
/** Transaction data (hex encoded) */
data: string;
}
/**
* Represents the request body for a relayer operation.
*/
export interface RelayRequestBody {
/** Withdrawal details */
withdrawal: WithdrawalRelayerPayload;
withdrawal: SdkWithdrawal;
/** Public signals as string array */
publicSignals: string[];
/** Proof details */
proof: ProofRelayerPayload;
/** Fee commitment */
feeCommitment?: FeeCommitment;
/** Pool scope */
scope: string;
/** Chain ID to process the request on */
@@ -65,8 +58,9 @@ export interface RelayRequestBody {
*/
export interface WithdrawalPayload {
readonly proof: WithdrawalProof;
readonly withdrawal: Withdrawal;
readonly withdrawal: SdkWithdrawal;
readonly scope: bigint;
readonly feeCommitment?: FeeCommitment;
}
/**

View File

@@ -2,6 +2,7 @@ import { NextFunction, Request, Response } from "express";
import { ValidationError } from "../../exceptions/base.exception.js";
import { validateDetailsQuerystring } from "../../schemes/relayer/details.scheme.js";
import { validateRelayRequestBody } from "../../schemes/relayer/request.scheme.js";
import { validateQuoteBody } from "../../schemes/relayer/quote.scheme.js";
// Middleware to validate the details querying
export function validateDetailsMiddleware(
@@ -34,3 +35,20 @@ export function validateRelayRequestMiddleware(
}
next();
}
// Middleware to validate the quote
export function validateQuoteMiddleware(
req: Request,
res: Response,
next: NextFunction,
) {
const isValid = validateQuoteBody(req.body);
if (!isValid) {
const messages: string[] = [];
validateQuoteBody.errors?.forEach(e => e?.message ? messages.push(e.message) : undefined);
next(ValidationError.invalidInput({ message: messages.join("\n") }));
return;
}
next();
}

View File

@@ -1,7 +1,12 @@
import { Web3Provider } from "./web3.provider.js";
import { UniswapProvider } from "./uniswap.provider.js"
import { QuoteProvider } from "./quote.provider.js";
export { db } from "./db.provider.js";
export { SdkProvider } from "./sdk.provider.js";
export { SqliteDatabase } from "./sqlite.provider.js";
export { db } from "./db.provider.js";
export { UniswapProvider } from "./uniswap.provider.js"
export const web3Provider = new Web3Provider();
export const uniswapProvider = new UniswapProvider();
export const quoteProvider = new QuoteProvider();

View File

@@ -0,0 +1,14 @@
import { Address } from "viem";
import { uniswapProvider } from "./index.js";
export class QuoteProvider {
constructor() {
}
async quoteNativeTokenInERC20(chainId: number, addressIn: Address, amountIn: bigint): Promise<{ num: bigint, den: bigint }> {
const { in: in_, out } = (await uniswapProvider.quoteNativeToken(chainId, addressIn, amountIn))!;
return { num: out.amount, den: in_.amount };
}
}

View File

@@ -0,0 +1,90 @@
import { Token } from '@uniswap/sdk-core'
import { FeeAmount } from '@uniswap/v3-sdk'
import { Address, getContract } from 'viem'
import { web3Provider } from '../providers/index.js'
import { BlockchainError, RelayerError } from '../exceptions/base.exception.js'
import { isViemError } from '../utils.js'
import { QUOTER_CONTRACT_ADDRESS, WRAPPED_NATIVE_TOKEN_ADDRESS } from './uniswap/constants.js'
import { IERC20MinimalABI } from './uniswap/erc20.abi.js'
import { QuoterV2ABI } from './uniswap/quoterV2.abi.js'
export type UniswapQuote = {
chainId: number;
addressIn: string;
addressOut: string;
amountIn: bigint;
};
type QuoteToken = { amount: bigint, decimals: number }
export type Quote = {
in: QuoteToken
out: QuoteToken
};
export class UniswapProvider {
async getTokenInfo(chainId: number, address: Address): Promise<Token> {
const contract = getContract({
address,
abi: IERC20MinimalABI.abi,
client: web3Provider.client(chainId)
});
const [decimals, symbol] = await Promise.all([
contract.read.decimals(),
contract.read.symbol(),
])
return new Token(chainId, address, Number(decimals), symbol);
}
async quoteNativeToken(chainId: number, addressIn: Address, amountIn: bigint): Promise<Quote> {
const addressOut = WRAPPED_NATIVE_TOKEN_ADDRESS[chainId.toString()]!
return this.quote({
chainId,
amountIn,
addressOut,
addressIn
});
}
async quote({ chainId, addressIn, addressOut, amountIn }: UniswapQuote) {
const tokenIn = await this.getTokenInfo(chainId, addressIn as Address);
const tokenOut = await this.getTokenInfo(chainId, addressOut as Address);
const quoterContract = getContract({
address: QUOTER_CONTRACT_ADDRESS[chainId.toString()]!,
abi: QuoterV2ABI.abi,
client: web3Provider.client(chainId)
});
try {
const quotedAmountOut = await quoterContract.simulate.quoteExactInputSingle([{
tokenIn: tokenIn.address as Address,
tokenOut: tokenOut.address as Address,
fee: FeeAmount.MEDIUM,
amountIn,
sqrtPriceLimitX96: 0n,
}])
// amount, sqrtPriceX96After, tickAfter, gasEstimate
const [amount, , , ] = quotedAmountOut.result;
return {
in: {
amount: amountIn, decimals: tokenIn.decimals
},
out: {
amount, decimals: tokenOut.decimals
}
};
} catch (error) {
if (error instanceof Error && isViemError(error)) {
const { metaMessages, shortMessage } = error;
throw BlockchainError.txError((metaMessages ? metaMessages[0] : undefined) || shortMessage)
} else {
throw RelayerError.unknown("Something went wrong while quoting")
}
}
}
}

View File

@@ -0,0 +1,30 @@
import { Address } from "viem";
/**
* Mainnet, Polygon, Optimism, Arbitrum, Testnets Address
* source: https://github.com/Uniswap/v3-periphery/blob/main/deploys.md
*/
export const QUOTER_CONTRACT_ADDRESS: Record<string, Address> = {
"1": "0x61fFE014bA17989E743c5F6cB21bF9697530B21e", // Ethereum
"137": "0x61fFE014bA17989E743c5F6cB21bF9697530B21e", // polygon
"10": "0x61fFE014bA17989E743c5F6cB21bF9697530B21e", // Optimism
"42161": "0x61fFE014bA17989E743c5F6cB21bF9697530B21e", // Arbitrum
"11155111": "0xEd1f6473345F45b75F8179591dd5bA1888cf2FB3", // Sepolia
};
export const FACTORY_CONTRACT_ADDRESS: Record<string, Address> = {
"1": "0x1F98431c8aD98523631AE4a59f267346ea31F984", // Ethereum
"137": "0x1F98431c8aD98523631AE4a59f267346ea31F984", // polygon
"10": "0x1F98431c8aD98523631AE4a59f267346ea31F984", // Optimism
"42161": "0x1F98431c8aD98523631AE4a59f267346ea31F984", // Arbitrum
"11155111": "0x0227628f3f023bb0b980b67d528571c95c6dac1c", // Sepolia
}
export const WRAPPED_NATIVE_TOKEN_ADDRESS: Record<string, Address> = {
"1": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", // mainnet (WETH)
"137": "0x0000000000000000000000000000000000001010", // polygon (POL)
// "137": "0x0d500B1d8E8eF31E21C99d1Db9A6444d3ADf1270", // (WPOL) TODO: compare which token to use
"10": "0x4200000000000000000000000000000000000006", // Optimism (WETH)
"42161": "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1", // Arbitrum (WETH)
"11155111": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", // sepolia (WETH)
}

View File

@@ -0,0 +1,202 @@
export const IERC20MinimalABI = {
"abi": [
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "owner",
"type": "address"
},
{
"indexed": true,
"internalType": "address",
"name": "spender",
"type": "address"
},
{
"indexed": false,
"internalType": "uint256",
"name": "value",
"type": "uint256"
}
],
"name": "Approval",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "from",
"type": "address"
},
{
"indexed": true,
"internalType": "address",
"name": "to",
"type": "address"
},
{
"indexed": false,
"internalType": "uint256",
"name": "value",
"type": "uint256"
}
],
"name": "Transfer",
"type": "event"
},
{
"inputs": [
{
"internalType": "address",
"name": "owner",
"type": "address"
},
{
"internalType": "address",
"name": "spender",
"type": "address"
}
],
"name": "allowance",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "spender",
"type": "address"
},
{
"internalType": "uint256",
"name": "amount",
"type": "uint256"
}
],
"name": "approve",
"outputs": [
{
"internalType": "bool",
"name": "",
"type": "bool"
}
],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "account",
"type": "address"
}
],
"name": "balanceOf",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "recipient",
"type": "address"
},
{
"internalType": "uint256",
"name": "amount",
"type": "uint256"
}
],
"name": "transfer",
"outputs": [
{
"internalType": "bool",
"name": "",
"type": "bool"
}
],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "sender",
"type": "address"
},
{
"internalType": "address",
"name": "recipient",
"type": "address"
},
{
"internalType": "uint256",
"name": "amount",
"type": "uint256"
}
],
"name": "transferFrom",
"outputs": [
{
"internalType": "bool",
"name": "",
"type": "bool"
}
],
"stateMutability": "nonpayable",
"type": "function"
},
{
"constant": true,
"inputs": [],
"name": "decimals",
"outputs": [
{
"name": "",
"type": "uint256"
}
],
"payable": false,
"stateMutability": "view",
"type": "function"
},
{
"constant": true,
"inputs": [],
"name": "symbol",
"outputs": [
{
"name": "",
"type": "string"
}
],
"payable": false,
"stateMutability": "view",
"type": "function"
}
],
} as const;

View File

@@ -0,0 +1,195 @@
export const QuoterABI = {
"abi": [
{
"inputs": [
{
"internalType": "address",
"name": "_factory",
"type": "address"
},
{
"internalType": "address",
"name": "_WETH9",
"type": "address"
}
],
"stateMutability": "nonpayable",
"type": "constructor"
},
{
"inputs": [],
"name": "WETH9",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "factory",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "bytes",
"name": "path",
"type": "bytes"
},
{
"internalType": "uint256",
"name": "amountIn",
"type": "uint256"
}
],
"name": "quoteExactInput",
"outputs": [
{
"internalType": "uint256",
"name": "amountOut",
"type": "uint256"
}
],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "tokenIn",
"type": "address"
},
{
"internalType": "address",
"name": "tokenOut",
"type": "address"
},
{
"internalType": "uint24",
"name": "fee",
"type": "uint24"
},
{
"internalType": "uint256",
"name": "amountIn",
"type": "uint256"
},
{
"internalType": "uint160",
"name": "sqrtPriceLimitX96",
"type": "uint160"
}
],
"name": "quoteExactInputSingle",
"outputs": [
{
"internalType": "uint256",
"name": "amountOut",
"type": "uint256"
}
],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "bytes",
"name": "path",
"type": "bytes"
},
{
"internalType": "uint256",
"name": "amountOut",
"type": "uint256"
}
],
"name": "quoteExactOutput",
"outputs": [
{
"internalType": "uint256",
"name": "amountIn",
"type": "uint256"
}
],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "tokenIn",
"type": "address"
},
{
"internalType": "address",
"name": "tokenOut",
"type": "address"
},
{
"internalType": "uint24",
"name": "fee",
"type": "uint24"
},
{
"internalType": "uint256",
"name": "amountOut",
"type": "uint256"
},
{
"internalType": "uint160",
"name": "sqrtPriceLimitX96",
"type": "uint160"
}
],
"name": "quoteExactOutputSingle",
"outputs": [
{
"internalType": "uint256",
"name": "amountIn",
"type": "uint256"
}
],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "int256",
"name": "amount0Delta",
"type": "int256"
},
{
"internalType": "int256",
"name": "amount1Delta",
"type": "int256"
},
{
"internalType": "bytes",
"name": "path",
"type": "bytes"
}
],
"name": "uniswapV3SwapCallback",
"outputs": [],
"stateMutability": "view",
"type": "function"
}
],
} as const;

View File

@@ -0,0 +1,269 @@
export const QuoterV2ABI = {
"abi": [
{
"inputs": [
{
"internalType": "address",
"name": "_factory",
"type": "address"
},
{
"internalType": "address",
"name": "_WETH9",
"type": "address"
}
],
"stateMutability": "nonpayable",
"type": "constructor"
},
{
"inputs": [],
"name": "WETH9",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "factory",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "bytes",
"name": "path",
"type": "bytes"
},
{
"internalType": "uint256",
"name": "amountIn",
"type": "uint256"
}
],
"name": "quoteExactInput",
"outputs": [
{
"internalType": "uint256",
"name": "amountOut",
"type": "uint256"
},
{
"internalType": "uint160[]",
"name": "sqrtPriceX96AfterList",
"type": "uint160[]"
},
{
"internalType": "uint32[]",
"name": "initializedTicksCrossedList",
"type": "uint32[]"
},
{
"internalType": "uint256",
"name": "gasEstimate",
"type": "uint256"
}
],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"components": [
{
"internalType": "address",
"name": "tokenIn",
"type": "address"
},
{
"internalType": "address",
"name": "tokenOut",
"type": "address"
},
{
"internalType": "uint256",
"name": "amountIn",
"type": "uint256"
},
{
"internalType": "uint24",
"name": "fee",
"type": "uint24"
},
{
"internalType": "uint160",
"name": "sqrtPriceLimitX96",
"type": "uint160"
}
],
"internalType": "struct IQuoterV2.QuoteExactInputSingleParams",
"name": "params",
"type": "tuple"
}
],
"name": "quoteExactInputSingle",
"outputs": [
{
"internalType": "uint256",
"name": "amountOut",
"type": "uint256"
},
{
"internalType": "uint160",
"name": "sqrtPriceX96After",
"type": "uint160"
},
{
"internalType": "uint32",
"name": "initializedTicksCrossed",
"type": "uint32"
},
{
"internalType": "uint256",
"name": "gasEstimate",
"type": "uint256"
}
],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "bytes",
"name": "path",
"type": "bytes"
},
{
"internalType": "uint256",
"name": "amountOut",
"type": "uint256"
}
],
"name": "quoteExactOutput",
"outputs": [
{
"internalType": "uint256",
"name": "amountIn",
"type": "uint256"
},
{
"internalType": "uint160[]",
"name": "sqrtPriceX96AfterList",
"type": "uint160[]"
},
{
"internalType": "uint32[]",
"name": "initializedTicksCrossedList",
"type": "uint32[]"
},
{
"internalType": "uint256",
"name": "gasEstimate",
"type": "uint256"
}
],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"components": [
{
"internalType": "address",
"name": "tokenIn",
"type": "address"
},
{
"internalType": "address",
"name": "tokenOut",
"type": "address"
},
{
"internalType": "uint256",
"name": "amount",
"type": "uint256"
},
{
"internalType": "uint24",
"name": "fee",
"type": "uint24"
},
{
"internalType": "uint160",
"name": "sqrtPriceLimitX96",
"type": "uint160"
}
],
"internalType": "struct IQuoterV2.QuoteExactOutputSingleParams",
"name": "params",
"type": "tuple"
}
],
"name": "quoteExactOutputSingle",
"outputs": [
{
"internalType": "uint256",
"name": "amountIn",
"type": "uint256"
},
{
"internalType": "uint160",
"name": "sqrtPriceX96After",
"type": "uint160"
},
{
"internalType": "uint32",
"name": "initializedTicksCrossed",
"type": "uint32"
},
{
"internalType": "uint256",
"name": "gasEstimate",
"type": "uint256"
}
],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "int256",
"name": "amount0Delta",
"type": "int256"
},
{
"internalType": "int256",
"name": "amount1Delta",
"type": "int256"
},
{
"internalType": "bytes",
"name": "path",
"type": "bytes"
}
],
"name": "uniswapV3SwapCallback",
"outputs": [],
"stateMutability": "view",
"type": "function"
}
]
} as const;

View File

@@ -1,14 +1,30 @@
import { Chain, createPublicClient, http, PublicClient } from "viem";
import { Chain, createPublicClient, Hex, http, PublicClient, verifyTypedData } from "viem";
import {
CONFIG
CONFIG,
getSignerPrivateKey
} from "../config/index.js";
import { createChainObject } from "../utils.js";
import { privateKeyToAccount } from "viem/accounts";
import { FeeCommitment } from "../interfaces/relayer/common.js";
interface IWeb3Provider {
client(chainId: number): PublicClient;
getGasPrice(chainId: number): Promise<bigint>;
}
const domain = (chainId: number) => ({
name: "Privacy Pools Relayer",
version: "1",
chainId,
} as const)
const RelayerCommitmentTypes = {
RelayerCommitment: [
{ name: "withdrawalData", type: "bytes" },
{ name: "expiration", type: "uint256" },
]
} as const;
/**
* Class representing the provider for interacting with several chains
*/
@@ -42,4 +58,34 @@ export class Web3Provider implements IWeb3Provider {
return await this.client(chainId).getGasPrice()
}
async signRelayerCommitment(chainId: number, commitment: Omit<FeeCommitment, 'signedRelayerCommitment'>) {
const signer = privateKeyToAccount(getSignerPrivateKey(chainId) as Hex);
const { withdrawalData, expiration } = commitment;
return signer.signTypedData({
domain: domain(chainId),
types: RelayerCommitmentTypes,
primaryType: 'RelayerCommitment',
message: {
withdrawalData,
expiration: BigInt(expiration)
}
})
}
async verifyRelayerCommitment(chainId: number, commitment: FeeCommitment): Promise<boolean> {
const signer = privateKeyToAccount(getSignerPrivateKey(chainId) as Hex);
const { withdrawalData, expiration, signedRelayerCommitment } = commitment;
return verifyTypedData({
address: signer.address,
domain: domain(chainId),
types: RelayerCommitmentTypes,
primaryType: 'RelayerCommitment',
message: {
withdrawalData,
expiration: BigInt(expiration)
},
signature: signedRelayerCommitment
})
}
}

View File

@@ -1,12 +1,18 @@
import { Router } from "express";
import {
relayerDetailsHandler,
relayQuoteHandler,
relayRequestHandler,
} from "../handlers/index.js";
import { validateDetailsMiddleware, validateRelayRequestMiddleware } from "../middlewares/relayer/request.js";
import {
validateDetailsMiddleware,
validateQuoteMiddleware,
validateRelayRequestMiddleware
} from "../middlewares/relayer/request.js";
// Router setup
const relayerRouter = Router();
relayerRouter.get("/details", [
validateDetailsMiddleware,
relayerDetailsHandler
@@ -17,4 +23,10 @@ relayerRouter.post("/request", [
relayRequestHandler,
]);
relayerRouter.post("/quote", [
validateQuoteMiddleware,
relayQuoteHandler
]);
export { relayerRouter };

View File

@@ -0,0 +1,18 @@
import { Ajv, JSONSchemaType } from "ajv";
import { QuotetBody } from "../../interfaces/relayer/quote.js";
// AJV schema for validation
const ajv = new Ajv();
const quoteSchema: JSONSchemaType<QuotetBody> = {
type: "object",
properties: {
chainId: { type: ["string", "number"] },
amount: { type: ["string"] },
asset: { type: ["string"] },
recipient: { type: ["string"], nullable: true },
},
required: ["chainId", "amount", "asset"],
} as const;
export const validateQuoteBody = ajv.compile(quoteSchema);

View File

@@ -42,6 +42,16 @@ const relayRequestSchema: JSONSchemaType<RelayRequestBody> = {
},
scope: { type: "string" },
chainId: { type: ["string", "number"] },
feeCommitment: {
type: "object",
properties: {
expiration: { type: "number" },
withdrawalData: { type: "string", pattern: "0x[0-9a-fA-F]+" },
signedRelayerCommitment: { type: "string", pattern: "0x[0-9a-fA-F]+" }
},
nullable: true,
required: ["expiration", "signedRelayerCommitment"]
}
},
required: ["withdrawal", "proof", "publicSignals", "scope", "chainId"],
} as const;

View File

@@ -1,3 +1,6 @@
export { PrivacyPoolRelayer } from "./privacyPoolRelayer.service.js";
import { PrivacyPoolRelayer } from "./privacyPoolRelayer.service.js";
import { QuoteService } from "./quote.service.js";
export const privacyPoolRelayer = new PrivacyPoolRelayer();
export const quoteService = new QuoteService();

View File

@@ -17,10 +17,13 @@ import {
RelayerResponse,
WithdrawalPayload,
} from "../interfaces/relayer/request.js";
import { db, SdkProvider } from "../providers/index.js";
import { db, SdkProvider, web3Provider } from "../providers/index.js";
import { RelayerDatabase } from "../types/db.types.js";
import { SdkProviderInterface } from "../types/sdk.types.js";
import { decodeWithdrawalData, isViemError, parseSignals } from "../utils.js";
import { quoteService } from "./index.js";
import { Web3Provider } from "../providers/web3.provider.js";
import { FeeCommitment } from "../interfaces/relayer/common.js";
/**
* Class representing the Privacy Pool Relayer, responsible for processing withdrawal requests.
@@ -28,8 +31,10 @@ import { decodeWithdrawalData, isViemError, parseSignals } from "../utils.js";
export class PrivacyPoolRelayer {
/** Database instance for storing and updating request states. */
protected db: RelayerDatabase;
/** SDK provider for handling blockchain interactions. */
/** SDK provider for handling contract interactions. */
protected sdkProvider: SdkProviderInterface;
/** Web3 provider for handling blockchain interactions. */
protected web3Provider: Web3Provider;
/**
* Initializes a new instance of the Privacy Pool Relayer.
@@ -37,6 +42,7 @@ export class PrivacyPoolRelayer {
constructor() {
this.db = db;
this.sdkProvider = new SdkProvider();
this.web3Provider = web3Provider;
}
/**
@@ -162,7 +168,6 @@ export class PrivacyPoolRelayer {
);
const proofSignals = parseSignals(wp.proof.publicSignals);
if (wp.withdrawal.processooor !== entrypointAddress) {
throw WithdrawalValidationError.processooorMismatch(
`Processooor mismatch: expected "${entrypointAddress}", got "${wp.withdrawal.processooor}".`,
@@ -195,10 +200,32 @@ export class PrivacyPoolRelayer {
);
}
if (relayFeeBPS !== assetConfig.fee_bps) {
throw WithdrawalValidationError.feeMismatch(
`Relay fee mismatch: expected "${assetConfig.fee_bps}", got "${relayFeeBPS}".`,
);
if (wp.feeCommitment) {
if (commitmentExpired(wp.feeCommitment)) {
throw WithdrawalValidationError.relayerCommitmentRejected(
`Relay fee commitment expired, please quote again`,
);
}
if (!await validFeeCommitment(chainId, wp.feeCommitment)) {
throw WithdrawalValidationError.relayerCommitmentRejected(
`Invalid relayer commitment`,
);
}
} else {
const currentFeeBPS = await quoteService.quoteFeeBPSNative({
chainId, amountIn: proofSignals.withdrawnValue, assetAddress, baseFeeBPS: assetConfig.fee_bps, value: 0n
});
if (relayFeeBPS < currentFeeBPS) {
throw WithdrawalValidationError.feeTooLow(
`Relay fee too low: expected at least "${currentFeeBPS}", got "${relayFeeBPS}".`,
);
}
}
if (proofSignals.withdrawnValue < assetConfig.min_withdraw_amount) {
@@ -206,5 +233,15 @@ export class PrivacyPoolRelayer {
`Withdrawn value too small: expected minimum "${assetConfig.min_withdraw_amount}", got "${proofSignals.withdrawnValue}".`,
);
}
}
}
function commitmentExpired(feeCommitment: FeeCommitment): boolean {
return feeCommitment.expiration < Number(new Date())
}
async function validFeeCommitment(chainId: number, feeCommitment: FeeCommitment): Promise<boolean> {
return web3Provider.verifyRelayerCommitment(chainId, feeCommitment)
}

View File

@@ -0,0 +1,43 @@
import { Address } from "viem";
import { quoteProvider, web3Provider } from "../providers/index.js";
interface QuoteFeeBPSParams {
chainId: number,
assetAddress: Address,
amountIn: bigint,
value: bigint,
baseFeeBPS: bigint
};
const NativeAddress = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE";
export class QuoteService {
readonly txCost: bigint;
constructor() {
// a typical withdrawal costs between 450k-650k gas
this.txCost = 700_000n;
}
async netFeeBPSNative(baseFee: bigint, balance: bigint, nativeQuote: { num: bigint, den: bigint }, gasPrice: bigint, value: bigint): Promise<bigint> {
const nativeCosts = (1n * gasPrice * this.txCost + value)
return baseFee + nativeQuote.den * 10_000n * nativeCosts / balance / nativeQuote.num;
}
async quoteFeeBPSNative(quoteParams: QuoteFeeBPSParams): Promise<bigint> {
const { chainId, assetAddress, amountIn, baseFeeBPS, value } = quoteParams;
const gasPrice = await web3Provider.getGasPrice(chainId);
let quote: { num: bigint, den: bigint };
if (assetAddress.toLowerCase() === NativeAddress.toLowerCase()) {
quote = { num: 1n, den: 1n };
} else {
quote = await quoteProvider.quoteNativeTokenInERC20(chainId, assetAddress, amountIn);
}
const feeBPS = await this.netFeeBPSNative(baseFeeBPS, amountIn, quote, gasPrice, value);
return feeBPS
}
}

View File

@@ -1,5 +1,6 @@
import { Address } from "viem/accounts";
import { RelayerResponse } from "./interfaces/relayer/request.js";
import { QuoteResponse } from "./interfaces/relayer/quote.js";
export abstract class RelayerMarshall {
abstract toJSON(): object;
@@ -45,3 +46,25 @@ export class RequestMashall extends RelayerMarshall {
return this.response;
}
}
export class QuoteMarshall extends RelayerMarshall {
constructor(readonly response: QuoteResponse) {
super();
}
addFeeCommitment(feeCommitment: {
expiration: number
withdrawalData: `0x${string}`,
signedRelayerCommitment: `0x${string}`
}) {
this.response.feeCommitment = feeCommitment;
}
override toJSON(): object {
return {
baseFeeBPS: this.response.baseFeeBPS.toString(),
feeBPS: this.response.feeBPS.toString(),
feeCommitment: this.response.feeCommitment
}
}
}

View File

@@ -1,8 +1,11 @@
import {
Address,
Chain,
ContractFunctionExecutionError,
ContractFunctionRevertedError,
decodeAbiParameters, DecodeAbiParametersErrorType,
encodeAbiParameters,
EncodeAbiParametersErrorType,
BaseError as ViemError
} from "viem";
import {
@@ -15,7 +18,13 @@ import {
} from "./interfaces/relayer/request.js";
import { FeeDataAbi } from "./types/abi.types.js";
export function decodeWithdrawalData(data: `0x${string}`) {
interface WithdrawalData {
recipient: Address,
feeRecipient: Address,
relayFeeBPS: bigint
}
export function decodeWithdrawalData(data: `0x${string}`): WithdrawalData {
try {
const [{ recipient, feeRecipient, relayFeeBPS }] = decodeAbiParameters(
FeeDataAbi,
@@ -31,6 +40,18 @@ export function decodeWithdrawalData(data: `0x${string}`) {
}
}
export function encodeWithdrawalData(withdrawalData: WithdrawalData): `0x${string}` {
try {
return encodeAbiParameters(FeeDataAbi, [withdrawalData])
} catch (e) {
const error = e as EncodeAbiParametersErrorType;
throw WithdrawalValidationError.invalidWithdrawalAbi({
name: error.name,
message: error.message,
});
}
}
export function parseSignals(
signals: RelayRequestBody["publicSignals"],
): WithdrawPublicSignals {
@@ -61,9 +82,9 @@ export function parseSignals(
* @param {object} chainConfig - The chain configuration
* @returns {Chain} - The Chain object
*/
export function createChainObject(chainConfig: {
chain_id: number;
chain_name: string;
export function createChainObject(chainConfig: {
chain_id: number;
chain_name: string;
rpc_url: string;
native_currency?: { name: string; symbol: string; decimals: number };
}): Chain {

View File

@@ -29,3 +29,16 @@ export const request = async (requestBody) => {
});
console.log(JSON.stringify(await r.json(), null, 2));
};
export const quote = async (quoteBody) => {
let r = await fetch("http://localhost:3000/relayer/quote", {
method: "post",
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(quoteBody)
})
const quoteResponse = await r.json();
console.log(JSON.stringify(quoteResponse, null, 2))
return quoteResponse;
}

View File

@@ -1,6 +1,12 @@
import { Address, Hex } from "viem";
// // mainnet
// export const ENTRYPOINT_ADDRESS: Address = "0x6818809EefCe719E480a7526D76bD3e561526b46";
// export const ETH_POOL_ADDRESS: Address = "0xF241d57C6DebAe225c0F2e6eA1529373C9A9C9fB";
// localnet
export const ENTRYPOINT_ADDRESS: Address = "0xd6DB18A83F9eE4e2d0FC8D6BEd075A2905A83FDA";
export const ETH_POOL_ADDRESS: Address = "0x4Cb503503047b66aA5e64b9BDC8148E769ac52f6";
export const LOCAL_ANVIL_RPC = "http://127.0.0.1:8545";
export const PRIVATE_KEY: Hex = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";

View File

@@ -1,10 +1,20 @@
import { Hash, Withdrawal } from "@0xbow/privacy-pools-core-sdk";
import { encodeAbiParameters, getAddress, Hex } from "viem";
import { request } from "./api-test.js";
import { request, quote } from "./api-test.js";
import { anvilChain, pool } from "./chain.js";
import { ENTRYPOINT_ADDRESS } from "./constants.js";
import { deposit, proveWithdrawal } from "./create-withdrawal.js";
interface QuoteResponse {
baseFeeBPS: bigint,
feeBPS: bigint,
feeCommitment?: {
expiration: number,
withdrawalData: `0x${string}`,
signedRelayerCommitment: `0x${string}`,
}
}
const FeeDataAbi = [
{
name: "FeeData",
@@ -25,36 +35,104 @@ async function prove(w: Withdrawal, scope: bigint) {
return proveWithdrawal(w, scope);
}
async function depositEth() {
async function depositCli() {
const r = await deposit();
await r.wait();
console.log(`Successful deposit, hash := ${r.hash}`);
}
(async () => {
async function quoteReq(chainId: number, asset: string, recipient: string, amount: string) {
return (await quote({
chainId,
amount,
asset,
recipient
}) as QuoteResponse);
}
async function quoteCli(chainId: string, asset: string, amount?: string) {
const _amount = amount ? Number(amount) : 100_000_000_000_000_000n
quoteReq(Number(chainId), asset, recipient, _amount.toString())
}
async function relayCli(chainId: string, asset: string, withQuote: boolean) {
const scope = await pool.read.SCOPE() as Hash;
const data = encodeAbiParameters(FeeDataAbi, [
{
recipient,
feeRecipient: FEE_RECEIVER_ADDRESS,
relayFeeBPS: 1_000n,
},
]) as Hex;
let data;
let feeCommitment = undefined;
if (withQuote) {
const amount = "100000000000000000"; // 0.1 ETH
const quoteRes = await quoteReq(Number(chainId), asset, recipient, amount);
data = quoteRes.feeCommitment!.withdrawalData as Hex
feeCommitment = {
...quoteRes.feeCommitment,
};
} else {
data = encodeAbiParameters(FeeDataAbi, [
{
recipient,
feeRecipient: FEE_RECEIVER_ADDRESS,
relayFeeBPS: 100n,
},
]) as Hex;
}
const withdrawal = { processooor, data };
await depositEth();
// prove
const { proof, publicSignals } = await prove(withdrawal, scope);
const requestBody = {
scope: scope.toString(),
chainId: anvilChain.id,
withdrawal,
publicSignals,
proof,
feeCommitment
};
await request(requestBody);
}
async function cli() {
const args = process.argv.slice(2)
const action = args[0];
switch (action) {
case "deposit": {
console.log(action)
await depositCli();
break;
}
case "quote": {
console.log(action)
if (args.length < 3) {
throw Error("Not enough args")
}
await quoteCli(args[1]!, args[2]!, args[3])
break;
}
case "relay": {
console.log(...args)
const withQuote = args.includes("--with-quote")
const noFlags = args.slice(1).filter(a => a !== "--with-quote")
if (noFlags.length < 2) {
throw Error("Not enough args")
}
await relayCli(noFlags[0]!, noFlags[1]!, withQuote);
break;
}
case undefined: {
console.log("No action selected")
break;
}
}
}
(async () => {
cli();
})();

View File

@@ -469,6 +469,12 @@ describe("PrivacyPoolRelayer", () => {
);
});
it.skip("throws when feeCommitment has expired", async () => {})
it.skip("throws when feeCommitment is not verified", async () => {})
it.skip("throws when there is no feeCommitment and fee is lower than calculated", async () => {})
it("passes when all checks pass", async () => {
const withdrawalPayload: WithdrawalPayload = {
withdrawal: {