Для разработчиков / SENDIT

SenditBank

Один контракт хранит деньги всех аккаунтов SENDIT. Кошелёк один раз вносит депозит и регистрирует ключ; дальше ключ подписывает сделки, а каждая площадка сводит его обратно к аккаунту кошелька.

Модель

  • Аккаунты. Аккаунт - это адрес кошелька (или любой контракт, действующий как собственный аккаунт). Балансы хранятся в balanceOf[account][asset] и никогда не привязаны к ключу.
  • Активы. pUSD, ETH как address(0) и токены лаунчера, который добавил оператор (isAsset). Любой другой токен отклоняется. totalHeld[asset] - точная сумма всех балансов и никогда не больше того, что держит банк.
  • Ключи. Адреса, которые аккаунт зарегистрировал с доказательством, подписанным ключом. Ключ тратит баланс аккаунта на площадках, делает переводы, выводит на собственный кошелёк аккаунта, пополняет себе газ и отзывает себя. Он не может регистрировать ключи, менять лимиты или делать withdrawTo на другой адрес.
  • Площадки. Контракты, которым разрешено списывать с аккаунтов: движок, книга ордеров, хранилище, копи-фабрика, спот-роутер, обменник pUSD. Каждая вызывает bank.pull(msg.sender, asset, amount) со своим собственным вызывающим, так что площадка не может указать, чьи деньги она берёт. Каждая выплата возвращается через bank.push(account, ...), никогда подписанту.

Здесь нет receive(), нет изъятия, нет правки баланса, нет обновления и нет комиссии. ETH поступает только через deposit и push.

Аккаунты и accountOf

accountOf(sender) - это аккаунт, от имени которого действует отправитель: владелец, пока sender является ключом, зарегистрированным под текущим keyEpoch аккаунта и текущим globalEpoch, и сам sender в остальных случаях. Поэтому кошелёк сводится к самому себе, и каждая площадка работает и с подписью кошелька. Позиции, доли в хранилище, копи-счета и все события называют аккаунт, никогда не ключ.

Ключ устарел, когда accountOf(keyOf(account)) != account. Здесь нет playerOf: так та же идея называется в банке Arcade.

Доказательство ключа

Ключ соглашается обслуживать аккаунт, подписывая дайджест:

Solidity
keyDigest(account, key) = keccak256(abi.encode(
  "SENDIT/key-proof/v1", block.chainid, address(bank), account, key,
  keyEpoch[account], globalEpoch))

Доказательство - это подпись EIP-191 этих 32 байт ключом ("\x19Ethereum Signed Message:\n32" || digest): 65 байт, low s, v 27 или 28 (0 и 1 тоже принимаются). Затем кошелёк отправляет его через setKey(key, proof) или внутри deposit(pusdAmount, gasToKey, key, proof), который в той же транзакции может также оплатить газ ключа.

viem
import { encodeAbiParameters, parseAbiParameters, keccak256 } from "viem";
import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";

const key = privateKeyToAccount(generatePrivateKey()); // keep the private key safe

// ADD a key under the epochs in force: the bank computes the digest for you.
const digest = await client.readContract({
  address: bank, abi: bankAbi, functionName: "keyDigest", args: [account, key.address],
});
const proof = await key.signMessage({ message: { raw: digest } }); // EIP-191, 32 bytes

// REPLACE every key of the account: the same digest under keyEpoch + 1.
const [epoch, global] = await Promise.all([
  client.readContract({ address: bank, abi: bankAbi, functionName: "keyEpoch", args: [account] }),
  client.readContract({ address: bank, abi: bankAbi, functionName: "globalEpoch" }),
]);
const nextDigest = keccak256(encodeAbiParameters(
  parseAbiParameters("string, uint256, address, address, address, uint64, uint64"),
  ["SENDIT/key-proof/v1", 78270n, bank, account, key.address, epoch + 1n, global],
));
const replaceProof = await key.signMessage({ message: { raw: nextDigest } });
Как приложение создаёт свой ключ

Собственный фронтенд SENDIT выводит ключ из подписи кошелька под фиксированным сообщением EIP-4361 ("Unlock my SENDIT trading key...", привязанным к домену, сети, банку и обеим эпохам), поэтому каждое устройство заново выводит тот же ключ. Боту это не нужно: подойдёт любой ключ, который у вас есть, если его доказательство верно.

Эпохи: добавить или заменить

Доказательство проверяется по одной из двух пар эпох, и от того, по какой, зависит результат:

  • Текущие эпохи: ADD. Ключ присоединяется к действующим ключам аккаунта; все остальные ключи остаются действующими (один ключ на устройство).
  • keyEpoch + 1: REPLACE. Банк сначала отзывает ключи (эпоха сдвигается, все ключи прекращают действие, KeyRevoked), затем регистрирует ключ под новой эпохой.

revokeKey() прекращает действие всех ключей аккаунта; rotateKey(newKey, proof) отзывает и регистрирует в одной транзакции, с доказательством под новой эпохой. invalidateAllKeys() сдвигает globalEpoch и прекращает действие всех ключей в банке. keyOf(account) - это лишь последний зарегистрированный ключ, тот, куда идёт газ депозита.

Лимиты

Исходящие потоки pUSD и ETH ключа третьим сторонам (send, refuel и списания площадкой с лимитом, которой сегодня является спот-роутер) учитываются в дневных лимитах аккаунта в фиксированном 24-часовом окне от первого учтённого исходящего потока. Бессрочные контракты, хранилище и копирование без лимитов, запущенные токены тоже, а вызовы с подписью кошелька никогда не учитываются. По умолчанию в контракте 10,000 pUSD и 5 ETH в день; при развёртывании в тестовой сети их отключили (UNCAPPED, читайте defaultCaps()). Аккаунт задаёт свои лимиты через setKeyCaps со своего кошелька: 0 означает значение по умолчанию.

Пауза

setPaused(true) останавливает pull и move площадок, а также подписанные ключом send и refuel. Она никогда не останавливает withdraw, withdrawTo, push (поэтому позиции по-прежнему закрываются и выплаты приходят), депозиты, регистрацию и отзыв ключей и собственный send кошелька или контракта (копи-счёт платит копирующему через send). Обменник pUSD соблюдает паузу банка для обоих своих входов.

Площадки и 24-часовая задержка

Площадка может списать с любого аккаунта через передаваемого ею отправителя, поэтому площадки являются корнем доверия банка. Добавление площадки - это proposeVenue, затем activateVenue после VENUE_DELAY (24 часа): событие VenueProposed даёт каждому аккаунту сутки, чтобы сначала вывести средства. listVenueNow пропускает ожидание только до самого первого зачисления в банк. removeVenue действует немедленно, и удалённая площадка по-прежнему может делать push.

Функции и события

Where every SENDIT account's money lives: pUSD, ETH (address(0)) and launched tokens, keyed by the account (the wallet), never by a key. Venues debit it with pull and credit it with push.

Wallet

FunctionWhat it does
deposit(uint256 pusdAmount, uint256 gasToKey, address key, bytes keyProof) payableThe one wallet transaction: pulls pUSD with no approve, credits msg.value - gasToKey as ETH, registers key when non-zero, and sends gasToKey to the account's key. Every argument may be zero.
depositToken(address token, uint256 amount)Deposits a launched token (or pUSD) already in the wallet. A launched token needs an approve to the bank first; pUSD does not.
setKey(address key, bytes keyProof)Registers a key. A proof under the current epochs ADDS it; a proof under keyEpoch + 1 REPLACES every key of the account.
setKeyCaps(uint128 pusdPerDay, uint128 ethPerDay)The account's own daily caps on its keys' sends, spot buys and launches. 0 means the default, UNCAPPED means none. Wallet only.
withdrawTo(address asset, uint256 amount, address to)Wallet only: pays another address, for a smart account with no receive(). Never paused, never capped.

Wallet or key

FunctionWhat it does
withdraw(address asset, uint256 amount)Pays accountOf(msg.sender), the wallet, whoever signs. Never paused, never capped.
send(address to, address asset, uint256 amount, bool toWallet)From the signer's account to `to`: credited in the bank (toWallet false; a live key named as `to` credits its account) or paid out as the asset. Key-signed pUSD and ETH count against the caps and stop while paused.
revokeKey()Ends every key of the account: keyEpoch moves on, so every device derives a new key.
rotateKey(address newKey, bytes keyProof)revokeKey, then registers newKey with a proof under the NEW account epoch, in one transaction.
refuel() returns (uint256 amount)A live key below REFUEL_BELOW tops itself up to REFUEL_TO from its account's ETH, once per REFUEL_EVERY. Stops while paused.

Venues

FunctionWhat it does
pull(address sender, address asset, uint256 amount) returns (address account)A venue debits accountOf(sender) and receives the asset. The venue passes its own msg.sender, so it never names whose money it takes. Stops while paused.
push(address account, address asset, uint256 amount) payableA venue credits `account` with what it hands over in this call. Never paused, and a removed venue can still push, so payouts in flight land.
move(address sender, address to, address asset, uint256 amount) returns (address account)A venue debits accountOf(sender) and credits `to` with no token moving (how a copy account is funded). Stops while paused.

Reading an account

FunctionWhat it does
accountOf(address sender) view returns (address)The account behind a sender: the owner while sender is one of its live keys, sender itself otherwise.
balanceOf(address account, address asset) view returns (uint256)One balance. asset address(0) is ETH.
keyOf(address account) view returns (address)The key registered last (address(0) after a revoke). Not proof that it is live: compare accountOf(keyOf(account)) with account.
ownerOfKey(address key) view returns (address)The account a key was registered for, live or not.
keyEpoch(address account) view returns (uint64)The account's key epoch. A revoke adds one.
keyAccountEpochOf(address key) view returns (uint64)The account epoch a key was registered under.
keyGlobalEpochOf(address key) view returns (uint64)The global epoch a key was registered under.
globalEpoch() view returns (uint64)The bank-wide epoch. invalidateAllKeys adds one.
keyDigest(address account, address key) view returns (bytes32)What the key signs (EIP-191) to consent to serving the account under the epochs in force now.
keyCapsOf(address account) view returns (uint256 pusdPerDay, uint256 ethPerDay, uint256 pusdSpent, uint256 ethSpent, uint64 windowEnds)The caps in force and what the keys spent in the current 24-hour window (zeros once it ended).
lastRefuelAt(address key) view returns (uint64)When a key last refuelled.
totalHeld(address asset) view returns (uint256)The sum of every balance of an asset. Never more than the bank holds.
isAsset(address asset) view returns (bool)ETH, pUSD, or a token launched by a launcher the operator added.

Reading the bank

FunctionWhat it does
pusd() view returns (address)The pUSD token.
operator() view returns (address)The operator: pause, venues, default caps, launchers, the key brake.
paused() view returns (bool)Whether venue pulls and moves and key-signed sends and refuels are stopped.
everFunded() view returns (bool)Set by the first credit; closes listVenueNow for ever.
defaultCaps() view returns (uint128 pusdPerDay, uint128 ethPerDay)The caps an account gets when it set none.
isVenue(address) view returns (bool)Whether an address may pull and move.
isCappedVenue(address) view returns (bool)Whether a key's pUSD and ETH spent through this venue count against the caps (the spot router).
wasVenue(address) view returns (bool)Ever listed: may still push after a removal.
venueActiveAt(address) view returns (uint64)When a proposed venue may be activated; 0 when none is pending.
proposedCapped(address) view returns (bool)The capped flag a pending venue will be listed with.
isLauncher(address) view returns (bool)Whether a launcher's tokens are accepted.
launchers(uint256) view returns (address)The added launchers, by index.
launcherCount() view returns (uint256)How many launchers were added.

Operator

FunctionWhat it does
proposeVenue(address venue, bool capped)Starts the VENUE_DELAY wait before a venue can be listed.
activateVenue(address venue)Lists a proposed venue once its delay has passed.
listVenueNow(address venue, bool capped)Lists a venue at once, only while the bank has never been credited (how the deploy lists its own venues).
removeVenue(address venue)Immediate. The venue can no longer pull or move, but can still push.
setPaused(bool paused_)The pause (see the pause rules).
setDefaultCaps(uint128 pusdPerDay, uint128 ethPerDay)The default caps. Both must be non-zero.
addLauncher(address launcher)Add-only: a launcher's tokens stay accepted for ever.
invalidateAllKeys()Moves globalEpoch on: every key on the bank stops resolving at once. Moves no balance; costs every account one setKey.
setOperator(address next)Hands the operator role on.

Constants

FunctionWhat it does
VERSION() view returns (uint256)1.
ETH() view returns (address)address(0), the ETH asset.
VENUE_DELAY() view returns (uint64)24 hours between proposeVenue and activateVenue.
CAP_WINDOW() view returns (uint64)24 hours, from the first charged outflow.
UNCAPPED() view returns (uint128)type(uint128).max: a cap of this value is no cap.
REFUEL_BELOW() view returns (uint256)0.002 ETH: a key may refuel below this.
REFUEL_TO() view returns (uint256)0.01 ETH: what a refuel tops the key up to.
REFUEL_EVERY() view returns (uint256)1 hour between two refuels of one key.

Events

EventWhat it does
Deposited(address indexed account, address indexed asset, uint256 amount)A deposit credited, pUSD, ETH or a token.
GasSent(address indexed account, address indexed key, uint256 amount, bool fromBalance)ETH sent to a key: the deposit's gas seed (fromBalance false) or a refuel (true).
Withdrawn(address indexed account, address indexed asset, uint256 amount, address to, address by)A withdrawal; `by` is the signer, a key or the wallet.
Sent(address indexed from, address indexed to, address indexed asset, uint256 amount, bool toWallet, address by)A send, in the bank or paid out.
Pulled(address indexed venue, address indexed account, address indexed asset, uint256 amount, address by)A venue took money from an account; `by` is who signed.
Pushed(address indexed venue, address indexed account, address indexed asset, uint256 amount)A venue paid an account.
Moved(address indexed venue, address indexed from, address indexed to, address asset, uint256 amount, address by)A venue moved money between two accounts.
KeySet(address indexed account, address indexed key, uint64 accountEpoch, uint64 globalEpoch)A key registered.
KeyRevoked(address indexed account, address indexed key, uint64 nextEpoch, address by)Every key of an account ended.
KeysInvalidated(uint64 globalEpoch)The operator ended every key on the bank.
CapsSet(address indexed account, uint256 pusdPerDay, uint256 ethPerDay)Caps changed; account address(0) is the default.
VenueProposed(address indexed venue, bool capped, uint64 activeAt)A venue proposed; activeAt is when it can be listed.
VenueSet(address indexed venue, bool allowed, bool capped)A venue listed or removed.
Paused(bool paused)The pause switched.
OperatorSet(address indexed previous, address indexed next)The operator changed.
LauncherAdded(address indexed launcher)A launcher's tokens accepted.