面向开发者 / 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:那是街机银行合约对同一概念的称呼。
密钥证明
密钥通过签署一个摘要,表示同意为该账户服务:
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。
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。
函数与事件
SenditBank
bankWhere 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
| Function | What it does |
|---|---|
| deposit(uint256 pusdAmount, uint256 gasToKey, address key, bytes keyProof) payable | The 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
| Function | What 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
| Function | What 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) payable | A 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
| Function | What 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
| Function | What 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
| Function | What 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
| Function | What 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
| Event | What 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. |