Skip to main content

Smart Contracts

SodaPop runs on two contracts deployed at the same addresses on every supported chain.

ContractAddress
PaymentFactory0x8dAE900Fb6A1179A69b1E70A49b8399c6b9e4B19
PaymentWallet implementation0xc78652056DA8c993Ae59F9A4186e092942d46524
NetworkchainIdExplorer
Optimism10optimistic.etherscan.io
BNB Smart Chain56bscscan.com
Polygon137polygonscan.com
Base8453basescan.org
Arbitrum One42161arbiscan.io
Avalanche C-Chain43114snowtrace.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:

invoiceIdConverted to
Decimal digits, e.g. "1000456"That number
0x-prefixed hexThat 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:

payment-address.ts
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​

FunctionReturns
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)​

FunctionEffectVoids 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.

FunctionNotes
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​

Eventtopic0Emitted when
EthWithdrawn(user indexed, invoiceId, amount, executorFee, platformFee)0x624192059af3b196dbde111e6fdd936ec612fac7bf9f520320ee1a631c424eddNative token withdrawn from one invoice.
TokenWithdrawn(user indexed, token indexed, invoiceId, amount, executorFee, platformFee)0x72f9aa485b44be8b54a3cde8cbf6a4291fcf9800e748a95a54279a2f808be7fcERC-20 withdrawn from one invoice.
BatchEthWithdrawn(user indexed, settledMask, amount, executorFee, platformFee)0xb6c2fbc761ba530e7765246e8b7eec56701055a00b82cc64d1dec03ec379fea5Batch native withdrawal.
BatchTokenWithdrawn(user indexed, token indexed, settledMask, amount, executorFee, platformFee)0xd9aa9575bce4f4989a653950f879616376fa6a281f1e912ec51045ec042f0c86Batch ERC-20 withdrawal.
WalletExecuted(user indexed, invoiceId, target indexed, value)0xa220923fc2ad54d6bad443cb044f10fa2a8b6da6aef09a9f6eba4239ed0cb905Owner called walletExecute.
PayoutAddressSet(user indexed, payout)0x86595dc4ed4838fd84eeed07a9f42ea9af43a730d94d90334e779b82264c0d7cPayout address set.
PayoutAddressReset(user indexed)0xbaf24235f11066de8d317ed423205c34d352dd2bdf966e746440bb35989ae78aPayout address reset to the owner.
WithdrawFeeSet(user indexed, feeBps)0x64f4bc88c432868175af31fe623ff706067afe070c40f869f28e3daed63f8243Withdraw fee changed.
OwnerOnlyWithdrawSet(user indexed, enabled)0xdee5b73a2345a2575b15e4a67481481349375e46f58f6d82401d450358d5c58dOwner-only withdrawals toggled.
NonceInvalidated(user indexed, newNonce)0x1800cd2301fbc20790ed94f3d55a28ef2306a9c31cd3c72b5b71b6e4cf5c6241Owner voided outstanding signatures.
UserProfileSet(user indexed, name, logo)0x3113a951f523b2a7822f4c579707155cee95658beb6d67a22698616702ab55bbProfile 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:

ErrorMeaning
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.