Smart Contracts
SodaPop runs on two contracts deployed at the same addresses on every supported chain.
| Contract | Address |
|---|---|
| PaymentFactory | 0x8dAE900Fb6A1179A69b1E70A49b8399c6b9e4B19 |
| PaymentWallet implementation | 0xc78652056DA8c993Ae59F9A4186e092942d46524 |
| Network | chainId | Explorer |
|---|---|---|
| Optimism | 10 | optimistic.etherscan.io |
| BNB Smart Chain | 56 | bscscan.com |
| Polygon | 137 | polygonscan.com |
| Base | 8453 | basescan.org |
| Arbitrum One | 42161 | arbiscan.io |
| Avalanche C-Chain | 43114 | snowtrace.io |
ABI: PaymentFactory.json
You do not need to call the contracts to accept payments: the API does everything. This page is for verifying payments on-chain, computing payment addresses yourself, and managing your settings without the API.
Payment addresses
Each invoice has its own payment wallet: an
EIP-1167 minimal proxy deployed by
the factory with CREATE2. The address depends only on the owner and the
invoice ID, so it is known before the wallet exists. The wallet is deployed on
the first withdrawal.
salt = keccak256(abi.encode(owner, invoiceId))
paymentWallet = CREATE2(factory, salt, EIP-1167 proxy of PaymentWallet implementation)
On-chain: computeAddress(owner, invoiceId), or walletStatus(owner, invoiceId)
which also returns whether the wallet is deployed.
The on-chain invoiceId is a uint256. The API converts your invoiceId
string as follows:
invoiceId | Converted to |
|---|---|
Decimal digits, e.g. "1000456" | That number |
0x-prefixed hex | That number |
Hex digits only, e.g. "abc123" | That number, read as hex |
Anything else, e.g. "order-42" | uint256(keccak256(utf8 bytes)) |
Off-chain computation with viem:
import { concat, getAddress, keccak256, pad, toHex } from "viem";
const FACTORY = "0x8dAE900Fb6A1179A69b1E70A49b8399c6b9e4B19";
const WALLET_IMPL = "0xc78652056DA8c993Ae59F9A4186e092942d46524";
export function paymentAddress(owner: string, invoiceId: bigint) {
const initCode = concat([
"0x3d602d80600a3d3981f3363d3d373d3d3d363d73",
WALLET_IMPL,
"0x5af43d82803e903d91602b57fd5bf3",
]);
const salt = keccak256(
concat([pad(getAddress(owner)), pad(toHex(invoiceId))]),
);
const hash = keccak256(concat(["0xff", FACTORY, salt, keccak256(initCode)]));
return getAddress(`0x${hash.slice(-40)}`);
}
Owner settings
All settings are stored per owner and per chain.
Read
| Function | Returns |
|---|---|
getPayout(owner) | Where withdrawals go: the payout address if set, otherwise owner. |
payoutAddress(owner) | The stored payout address; 0x0 means not set. |
withdrawFeeBps(owner) / withdrawFeeConfigured(owner) | Your custom fee and whether you have set one. |
getEffectiveWithdrawFeeBps(owner, executor) | The fee a withdrawal by executor would take: 0 if executor == owner, your fee if set, otherwise DEFAULT_WITHDRAW_FEE_BPS (10). Ignores the owner-only flag. |
ownerOnlyWithdraw(owner) | Whether only you can withdraw your wallets. |
nonces(owner) | Nonce for SetFee / SetPayout signatures. |
getUserProfile(owner) | Your on-chain name and logo URL. |
getUserData(owner, executor) | All of the above in one call: payout, payoutConfigured, name, logo, feeBps, feeConfigured, effectiveFeeBps, nonce, ownerOnly. |
platformFeeBps() | Share of the withdraw fee that goes to the protocol treasury, in bps of the fee (max 5000). |
Write (called from the owner wallet)
| Function | Effect | Voids pending signatures |
|---|---|---|
setPayoutAddress(payout) | Send future withdrawals to payout. | Yes |
resetPayoutAddress() | Send withdrawals to the owner wallet again. | Yes |
setWithdrawFee(feeBps) | Set your withdraw fee, 0–500 bps. | Yes |
invalidateNonce() | Increment your nonce. | Yes |
setUserProfile(name, logo) | Set the name (up to 128 bytes) and logo URL (up to 512 bytes) shown to payers. | No |
setOwnerOnlyWithdraw(enabled) | Allow only yourself to withdraw your wallets. See owner-only withdrawals. | No |
Changing the payout address or fee directly increments your nonce, so a
SetFee / SetPayout signature you signed earlier can no longer restore an old
value.
The payout address cannot be the factory or the wallet implementation
(InvalidPayout()), and setPayoutAddress rejects 0x0 (ZeroAddress();
use resetPayoutAddress() instead).
Gasless settings
To change the fee or payout address without paying gas, sign SetFee /
SetPayout and submit them through the API; see
Fees and payout address. The factory executes
them with setFeeMetaTx / setPayoutMetaTx. The owner-only flag has no gasless
variant and can only be changed by the owner itself.
Withdrawals
Anyone can call a withdrawal, but funds always go to the owner's payout address. When the caller is not the owner, the effective withdraw fee is split between the caller and the protocol treasury; withdrawals by the owner are free.
| Function | Notes |
|---|---|
withdrawEth(owner, invoiceId) | Native token of one invoice. |
withdrawToken(owner, invoiceId, token) | One ERC-20 token of one invoice. |
batchWithdrawEth(owner, invoiceIds[]) | Up to 256 invoices of one owner, paid out in one transfer. |
batchWithdrawToken(owner, invoiceIds[], token) | Up to 256 invoices of one owner, one token, paid out in one transfer. |
batchWithdraw((owner, invoiceId, token)[]) | Up to 50 requests across owners and assets; token = 0x0 means the native token. One failing item reverts the whole call. |
A withdrawal from an empty wallet does nothing and does not revert, so racing another executor is harmless.
Owner-only withdrawals
After setOwnerOnlyWithdraw(true), any withdrawal of your wallets by someone
else reverts with OwnerOnlyWithdraw(), even if the wallet is empty. Use it
when the owner is a contract that must run its own logic on every deposit, for
example crediting the depositor encoded in the invoiceId: a third-party sweep
would deliver the funds without calling your code.
SodaPop never withdraws invoices of such owners. It still tracks them, marks
them paid and sends webhooks. Since you withdraw yourself, no withdraw fee
applies.
Recovering other assets
walletExecute(invoiceId, target, value, data) makes an arbitrary call from one
of your own payment wallets: the wallet is derived from the caller, so
nobody can reach someone else's. Use it to recover assets that the withdrawal
functions cannot move, such as NFTs (ERC-721, ERC-1155), tokens that charge a
fee on top of each transfer, or tokens your payout address cannot receive.
target cannot be the factory.
// Recover an NFT sent to an invoice address.
const wallet = await factory.read.computeAddress([owner, invoiceId]);
const data = encodeFunctionData({
abi: erc721Abi,
functionName: "transferFrom",
args: [wallet, owner, tokenId],
});
await factory.write.walletExecute([invoiceId, nftAddress, 0n, data]);
Events
| Event | topic0 | Emitted when |
|---|---|---|
EthWithdrawn(user indexed, invoiceId, amount, executorFee, platformFee) | 0x624192059af3b196dbde111e6fdd936ec612fac7bf9f520320ee1a631c424edd | Native token withdrawn from one invoice. |
TokenWithdrawn(user indexed, token indexed, invoiceId, amount, executorFee, platformFee) | 0x72f9aa485b44be8b54a3cde8cbf6a4291fcf9800e748a95a54279a2f808be7fc | ERC-20 withdrawn from one invoice. |
BatchEthWithdrawn(user indexed, settledMask, amount, executorFee, platformFee) | 0xb6c2fbc761ba530e7765246e8b7eec56701055a00b82cc64d1dec03ec379fea5 | Batch native withdrawal. |
BatchTokenWithdrawn(user indexed, token indexed, settledMask, amount, executorFee, platformFee) | 0xd9aa9575bce4f4989a653950f879616376fa6a281f1e912ec51045ec042f0c86 | Batch ERC-20 withdrawal. |
WalletExecuted(user indexed, invoiceId, target indexed, value) | 0xa220923fc2ad54d6bad443cb044f10fa2a8b6da6aef09a9f6eba4239ed0cb905 | Owner called walletExecute. |
PayoutAddressSet(user indexed, payout) | 0x86595dc4ed4838fd84eeed07a9f42ea9af43a730d94d90334e779b82264c0d7c | Payout address set. |
PayoutAddressReset(user indexed) | 0xbaf24235f11066de8d317ed423205c34d352dd2bdf966e746440bb35989ae78a | Payout address reset to the owner. |
WithdrawFeeSet(user indexed, feeBps) | 0x64f4bc88c432868175af31fe623ff706067afe070c40f869f28e3daed63f8243 | Withdraw fee changed. |
OwnerOnlyWithdrawSet(user indexed, enabled) | 0xdee5b73a2345a2575b15e4a67481481349375e46f58f6d82401d450358d5c58d | Owner-only withdrawals toggled. |
NonceInvalidated(user indexed, newNonce) | 0x1800cd2301fbc20790ed94f3d55a28ef2306a9c31cd3c72b5b71b6e4cf5c6241 | Owner voided outstanding signatures. |
UserProfileSet(user indexed, name, logo) | 0x3113a951f523b2a7822f4c579707155cee95658beb6d67a22698616702ab55bb | Profile changed. |
amount is what the payout address received; executorFee and platformFee
are the fee parts.
Batch events report totals only. settledMask tells which invoices were
settled: bit i is set when invoiceIds[i] of the call had a balance. To get
per-invoice amounts, use single withdrawals or compare wallet balances before
and after.
const settled = invoiceIds.filter((_, i) => (settledMask >> BigInt(i)) & 1n);
Errors
The factory uses custom errors:
| Error | Meaning |
|---|---|
OwnerOnlyWithdraw() | Withdrawal by a non-owner while owner-only withdrawals are enabled. |
BadNonce() | SetFee / SetPayout nonce does not match nonces(owner). |
SignatureExpired() | The signature deadline has passed. |
InvalidSignature() | The signature was not made by the owner. |
FeeTooHigh() | Fee above 500 bps. |
InvalidPayout() | Payout address is the factory or the wallet implementation. |
ZeroAddress() | A required address is 0x0. |
BatchTooLarge() | More than 256 invoices (50 for batchWithdraw). |
NameTooLong() / LogoTooLong() | Profile fields exceed their limits. |
InvalidTarget() | walletExecute targeted the factory. |
EthTransferFailed() | The payout address rejected the native-token transfer. |