面向开发者 / SENDIT

SenditBank

一个合约存放每个 SENDIT 账户的资金。钱包存入一次并登记一把密钥;之后由密钥签署交易,而每个场所都会把它解析回钱包的账户。

模型

  • 账户。账户是一个钱包地址(或任何作为自身账户行事的合约)。余额是 balanceOf[account][asset],从不以密钥为键。
  • 资产。pUSD、以 address(0) 表示的 ETH,以及运营者添加的发行器所发行的代币(isAsset)。其他任何代币都会被拒绝。totalHeld[asset] 是所有余额的精确总和,从不超过银行实际持有的数量。
  • 密钥。账户用一份由密钥签名的证明登记的地址。密钥可以在场所花费账户的余额、转账、提取到账户自己的钱包、为自己补充 gas 以及撤销。它不能登记密钥、修改上限,也不能 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:那是街机银行合约对同一概念的称呼。

密钥证明

密钥通过签署一个摘要,表示同意为该账户服务:

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

证明是密钥对这 32 字节的 EIP-191 签名("\x19Ethereum Signed Message:\n32" || digest):65 字节,low s,v 为 27 或 28(也接受 0 和 1)。随后钱包用 setKey(key, proof) 发送它,或放在 deposit(pusdAmount, gasToKey, key, proof) 中发送,后者还能在同一笔交易里为密钥注入 gas。

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) 只是最后登记的那把密钥,存入时附带的 gas 就发给它。

上限

密钥向第三方流出的 pUSD 和 ETH(send、refuel,以及受上限约束的场所发起的 pull,目前只有现货路由器)计入每个账户的每日上限,窗口为从第一笔计费流出开始的固定 24 小时。永续合约、金库和跟单不受上限约束,发行的代币也不受约束,钱包签名的调用从不计费。合约默认值为每天 10,000 pUSD 和 5 ETH;测试网部署把它们关闭了(UNCAPPED,读取 defaultCaps())。账户可以从自己的钱包用 setKeyCaps 设置自己的上限:0 表示使用默认值。

暂停

setPaused(true) 会停止场所的 pull 和 move,以及密钥签名的 send 和 refuel。它从不停止 withdraw、withdrawTo、push(所以仓位仍能平仓,支付仍能到账)、存入、密钥的登记与撤销,也不停止钱包或合约自己发起的 send(跟单账户通过 send 向跟单者付款)。pUSD 兑换合约的两个入口都遵从银行的暂停。

场所与 24 小时延迟

场所可以通过它传入的发送者从任何账户扣款,所以场所是银行的信任根。添加一个场所先调用 proposeVenue,经过 VENUE_DELAY(24 小时)之后再调用 activateVenue: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.