Para desenvolvedores / SENDIT
SenditBank
Um único contrato guarda o dinheiro de todas as contas do SENDIT. Uma carteira deposita uma vez e registra uma chave; a chave então assina as operações, e todo venue a resolve de volta para a conta da carteira.
O modelo
- Contas. Uma conta é um endereço de carteira (ou qualquer contrato que aja como a própria conta). Os saldos são
balanceOf[account][asset], nunca indexados por uma chave. - Ativos. pUSD, ETH como
address(0), e tokens de um launcher que o operador adicionou (isAsset). Qualquer outro token é recusado.totalHeld[asset]é a soma exata de todos os saldos e nunca é maior do que o que o banco detém. - Chaves. Endereços que uma conta registrou com uma prova assinada pela chave. Uma chave gasta o saldo da conta nos venues, envia, saca para a própria carteira da conta, se reabastece e se revoga. Ela não pode registrar chaves, mudar os tetos nem fazer
withdrawTopara outro endereço. - Venues. Os contratos autorizados a debitar contas: o motor, o livro de ordens, o cofre, a fábrica de cópias, o roteador spot, a casa de câmbio de pUSD. Cada um chama
bank.pull(msg.sender, asset, amount)com o próprio chamador, então um venue não pode escolher de quem é o dinheiro que ele pega. Todo pagamento volta combank.push(account, ...), nunca para o assinante.
Não há receive(), nem varredura, nem edição de saldo, nem upgrade, nem taxa. O ETH só entra por deposit e push.
Contas e accountOf
accountOf(sender) é a conta pela qual um remetente age: o dono enquanto sender for uma chave registrada sob o keyEpoch atual da conta e o globalEpoch atual, e o próprio sender caso contrário. Assim uma carteira resolve para si mesma, e todo venue também funciona com assinatura da carteira. Posições, cotas do cofre, contas de cópia e todos os eventos nomeiam a conta, nunca a chave.
Uma chave está obsoleta quando accountOf(keyOf(account)) != account. Não há playerOf aqui: esse é o nome que o banco do arcade dá à mesma ideia.
A prova da chave
A chave consente em servir a conta assinando um digest:
keyDigest(account, key) = keccak256(abi.encode( "SENDIT/key-proof/v1", block.chainid, address(bank), account, key, keyEpoch[account], globalEpoch))
A prova é a assinatura EIP-191 da chave sobre esses 32 bytes ("\x19Ethereum Signed Message:\n32" || digest): 65 bytes, s baixo, v 27 ou 28 (0 e 1 são aceitos). A carteira então a envia com setKey(key, proof) ou dentro de deposit(pusdAmount, gasToKey, key, proof), que também pode financiar o gas da chave na mesma transação.
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 } });O próprio front end do SENDIT deriva a chave de uma assinatura da carteira sobre uma mensagem EIP-4361 fixa ("Unlock my SENDIT trading key...", vinculada ao domínio, à cadeia, ao banco e às duas épocas), para que todo dispositivo derive de novo a mesma chave. Um bot não precisa disso: qualquer chave que você detenha funciona, desde que a prova esteja certa.
Épocas: adicionar ou substituir
Uma prova é verificada contra um de dois pares de épocas, e qual deles decide o que acontece:
- Épocas atuais: ADD. A chave se junta às chaves ativas da conta; todas as outras continuam ativas (uma chave por dispositivo).
- keyEpoch + 1: REPLACE. O banco revoga primeiro (a época avança, todas as chaves se encerram,
KeyRevoked) e depois registra a chave sob a nova época.
revokeKey() encerra todas as chaves da conta; rotateKey(newKey, proof) revoga e registra em uma só transação, com uma prova sob a nova época. invalidateAllKeys() avança o globalEpoch e encerra todas as chaves do banco. keyOf(account) é só a chave registrada por último, para onde vai o gas do depósito.
Tetos
As saídas de pUSD e ETH de uma chave para terceiros (send, refuel, e os pulls de um venue com teto, que hoje é o roteador spot) contam contra tetos diários por conta, sobre uma janela fixa de 24 horas a partir da primeira saída cobrada. Perps, o cofre e as cópias não têm teto, os tokens lançados também não, e as chamadas assinadas pela carteira nunca são cobradas. Os padrões do contrato são 10.000 pUSD e 5 ETH por dia; o deploy da rede de teste os desligou (UNCAPPED, leia defaultCaps()). Uma conta define os seus com setKeyCaps a partir da sua carteira: 0 significa o padrão.
Pausa
setPaused(true) interrompe pull e move dos venues, e send e refuel assinados por chave. Ele nunca interrompe withdraw, withdrawTo, push (então as posições continuam fechando e os pagamentos continuam chegando), depósitos, registro e revogação de chaves, nem o próprio send de uma carteira ou de um contrato (uma conta de cópia paga quem a copia por send). A casa de câmbio de pUSD respeita a pausa do banco nas suas duas portas.
Venues e o prazo de 24 horas
Um venue pode debitar qualquer conta por meio do remetente que repassa, então os venues são a raiz de confiança do banco. Adicionar um é proposeVenue, depois activateVenue após VENUE_DELAY (24 horas): um evento VenueProposed dá a cada conta um dia para sacar antes. listVenueNow pula a espera somente antes do primeiro crédito do banco. removeVenue é imediato, e um venue removido ainda pode fazer push.
Funções e eventos
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. |