Skip to content

Depository Contract ​

The DripToolsDepository is the on-chain half of a swap on the EVM FROM chains. It records that a payer paid a certain amount for a quoteId and emits a Paid event; everything else — recipients, TO chain, TO amounts — lives off-chain in the signed quote. This keeps the on-chain call tiny and identical whether you swap to one recipient or thousands.

Solana and TON have no contract

On the Solana and TON FROM chains the payment is a plain transfer to a treasury account with the quoteId as memo / comment. See Paying from Solana & TON. The quote signature described below is the same on every FROM chain.

Addresses ​

There is one pay target per FROM (mainnet) chain. Never hardcode it — read it at runtime:

  • From GET /api/chains: chain.contracts.depository.
  • From the quote response: the vault field is the same address, lowercased — compare case-insensitively.

If a FROM chain has no contracts.depository, programmatic swaps aren't enabled there yet.

Functions ​

All four payment functions take the quoteId and deadline from your quote. The paid amount must equal the quote's fromTotal, and the transaction must land before deadline.

FunctionForNotes
pay(bytes32 quoteId, uint256 deadline)Native FROM tokenpayable; msg.value == fromTotal.
payWithToken(address token, uint256 amount, bytes32 quoteId, uint256 deadline)ERC-20Requires a prior approve(payTarget, amount); amount == fromTotal.
payWithTokenPermit(address token, uint256 amount, bytes32 quoteId, uint256 deadline, uint256 permitDeadline, uint8 v, bytes32 r, bytes32 s)ERC-20 with EIP-2612No approve tx — the permit signature authorizes the pull in the same call. A permit that fails is ignored; the pull then relies on an existing allowance.
payWithPermit2(address token, uint256 amount, bytes32 quoteId, uint256 deadline, uint256 permit2Nonce, uint256 permit2Deadline, bytes permit2Signature)Any ERC-20 via Uniswap Permit2Uses a Permit2 allowance signature instead of EIP-2612.

token must be the same FROM token you quoted (quote.fromToken). A fee-on-transfer token that delivers less than amount reverts with TokenTransferMismatch; an expired deadline reverts with Expired.

The contract does not know fromTotal

The contract only checks a per-token minimum. Paying a different amount than the quote's fromTotal succeeds on-chain but is not honoured off-chain — the payment is held for operator review instead of becoming an order. Always pass exactly fromTotal.

ABI ​

Minimal ABI for the payment path (viem parseAbi form):

js
const depositoryAbi = parseAbi([
  'function pay(bytes32 quoteId, uint256 deadline) payable',
  'function payWithToken(address token, uint256 amount, bytes32 quoteId, uint256 deadline)',
  'function payWithTokenPermit(address token, uint256 amount, bytes32 quoteId, uint256 deadline, uint256 permitDeadline, uint8 v, bytes32 r, bytes32 s)',
  'function payWithPermit2(address token, uint256 amount, bytes32 quoteId, uint256 deadline, uint256 permit2Nonce, uint256 permit2Deadline, bytes permit2Signature)',
  'event Paid(address indexed payer, address indexed fromToken, uint256 fromAmount, bytes32 indexed quoteId)',
])

Reverts you can hit ​

The contract uses custom errors — decode a revert against this list:

ErrorMeaning
ExpiredThe transaction landed after deadline. Request a fresh quote.
MissingQuotequoteId is zero — you passed an empty quote id.
AmountTooLow / ZeroAmountThe paid amount is zero or below the contract's per-token floor.
TokenTransferMismatchThe token amount received ≠ amount (fee-on-transfer / rebasing tokens are not supported).
TokenNotAllowedThe FROM token isn't accepted by this contract — use one listed in /api/chains.
DirectPaymentNotAllowedA plain ETH transfer to the contract (no pay call) was rejected.
EnforcedPauseThe contract is paused by the operator. Try later.

Verifying the quote signature (optional) ​

The quote returned by POST /api/quote is signed with EIP-712, scheme version 3, by the address in signer. You don't have to check it — the backend does — but you can, to make sure a quote really came from Drip Tools before spending.

Domain

js
{
  name: 'drip.tools quote',
  version: '3',
  chainId: <FROM chainId>,             // number — the cross-chain replay separator
}

There is no verifyingContract in the domain: the pay target is signed as the depository message field instead, because a Solana or TON treasury does not fit a 20-byte address slot. The binding is the same — a quote is tied to exactly one (FROM chain, depository) pair and cannot be replayed against another chain or deployment.

Types

js
Quote: [
  { name: 'quoteId',    type: 'bytes32'   },
  { name: 'depository', type: 'string'    },  // == quote.vault
  { name: 'sender',     type: 'string'    },
  { name: 'fromToken',  type: 'string'    },
  { name: 'fromTotal',  type: 'uint256'   },
  { name: 'toChainId',  type: 'uint256'   },
  { name: 'toToken',    type: 'string'    },
  { name: 'recipients', type: 'string[]'  },
  { name: 'toAmounts',  type: 'uint256[]' },
  { name: 'issuedAt',   type: 'uint256'   },
  { name: 'expiry',     type: 'uint256'   },
]

Canonical string forms. Every string field is signed in the form the API derived from your request, so reproduce it exactly when verifying:

FieldCanonical form
depository, sender, fromTokenPer the FROM chain's family. EVM: lowercased 0x…. Solana: the base58 key exactly as sent. TON: the non-bounceable, url-safe UQ… form. The native sentinel 0x000…0 stays as is on every family.
toTokenLowercased 0x… (every TO chain; 0x000…0 for native).
recipients[]Per the TO chain's family: EVM lowercased; TON, Solana and Bitcoin addresses exactly as sent (trimmed).

depository and fromToken come back in canonical form on the quote (vault, fromToken); sender and recipients are yours to canonicalize.

Worked example (viem):

js
import { verifyTypedData } from 'viem'

const ok = await verifyTypedData({
  address: quote.signer,
  domain: { name: 'drip.tools quote', version: '3', chainId: FROM_CHAIN_ID },
  types: {
    Quote: [
      { name: 'quoteId',    type: 'bytes32'   },
      { name: 'depository', type: 'string'    },
      { name: 'sender',     type: 'string'    },
      { name: 'fromToken',  type: 'string'    },
      { name: 'fromTotal',  type: 'uint256'   },
      { name: 'toChainId',  type: 'uint256'   },
      { name: 'toToken',    type: 'string'    },
      { name: 'recipients', type: 'string[]'  },
      { name: 'toAmounts',  type: 'uint256[]' },
      { name: 'issuedAt',   type: 'uint256'   },
      { name: 'expiry',     type: 'uint256'   },
    ],
  },
  primaryType: 'Quote',
  message: {
    quoteId:    quote.quoteId,
    depository: quote.vault,                    // as returned
    sender:     account.address.toLowerCase(),  // EVM: lowercased; Solana: as sent; TON: UQ… form
    fromToken:  quote.fromToken,                // as returned
    fromTotal:  BigInt(quote.fromTotal),
    toChainId:  BigInt(TO_CHAIN_ID),
    toToken:    TO_TOKEN.toLowerCase(),
    recipients: [RECIPIENT.toLowerCase()],      // EVM recipients lowercased; TON/Solana/BTC as sent
    toAmounts:  quote.toAmounts.map(BigInt),
    issuedAt:   BigInt(quote.issuedAt),
    expiry:     BigInt(quote.expiry),
  },
  signature: quote.signature,
})
if (!ok) throw new Error('Quote signature does not verify — do not pay')