// SourceLess Chain — Intellectual property of Alexandru Marius Stratulat and the SourceLess team. // GitHub: alexccoin · Email: alex@strlabs.io · All rights reserved. No use without written consent. See LICENSE. // ========================================================================================= // ExchangeVault — one exchange's STR liquidity, held in the open, paid out on a public delay. // // WHAT IT IS FOR // Holding the native STR set aside for ONE exchange market, so that anyone can read the pool // as a live balance at a contract address rather than as a promise in a press release. The // balance IS the pool: `held()` reads the native ledger, and every movement out of it is an // event in this contract's feed. // // It can pay in exactly two directions and no third: // * to the exchange's DEPOSIT ADDRESS, once one is active, after a public delay; // * back to the RETURN ADDRESS fixed at deployment — the account that funded it. // // THE FOUR QUEUES, AND WHO MAY DO WHAT // Every power the owner has over where the money can go is an OPERATION: queued in public, // executable only after `delay()` milliseconds of block time, and dead `grace()` after that. // kind 1 a new deposit address `queueDestination` -> wait -> `activateDestination` // kind 2 a release of STR `queueRelease` -> wait -> `release` // (`queueRelease(amount, expected)` names the payee it expects, and refuses another) // kind 3 a new owner `queueOwner` -> wait -> `acceptOwnership` // kind 4 a return of STR `queueReturn` -> wait -> `executeReturn` // Queuing and cancelling are the owner's. Executing a matured kind 1, 2 or 4 is open to // ANYBODY: the owner's decision was taken, and announced, when it was queued, so the execute // step carries no authority of its own and needs no second signature from a custody quorum. // Accepting a kind 3 is for the named new owner only, which proves the new key exists. // Nothing moves the pool, its destination or its owner without a queued operation. // // WHAT IT GUARANTEES // * Nothing leaves except to the active deposit address or the return address. There is no // other `transfer` in this file; read it. The return address is a constant of the bytecode // and no function writes it. // * No deposit address takes effect sooner than `delay()` after it was queued, and none takes // effect at all without a sealed `activateDestination` the whole network can see. // * A release pays the deposit address that was active WHEN IT WAS QUEUED, or nothing. If the // destination changes in between, the queued release refuses with `destination changed`; // an amount announced against one address can never land at another. // * No release executes sooner than `delay()` after it was queued, and each executes at most // once: the status moves 1 to 2 before the transfer. // * A return pays only the return address, and no sooner than `delay()` after it was queued. // * A cancelled operation can never execute, and a cancellation cannot be undone. // * The owner's veto ends at the deadline. `cancel` requires `timestamp < etaOf(id)`, for every // kind; after that only execution or expiry ends an operation. // * A revert leaves the queue, the balance and the totals byte-identical. Storage and value // commit together, only on success. // * The clock is the SEALED BLOCK's timestamp. No function takes a time argument, so no // caller can bring a deadline forward. // // WHY A RETURN WAITS TOO // A pool that could be emptied at once is a pool a market cannot rely on: the exchange and its // traders would be reading a balance the owner can take away between two blocks. So a return // is announced exactly like a release and waits the same delay. Its recipient is still fixed // in the bytecode, so a stolen owner key can at most queue the pool back to the account that // funded it, in public, 72 hours ahead. // // WHY THE VETO ENDS AT THE DEADLINE // As in `timelock.ares`: once the wait is over the owner can no longer stop an operation, and // anybody may execute it. The delay is the time in which a mistake is caught and cancelled; a // veto that outlived it would make every eta a suggestion rather than a commitment. // // WHAT IT CANNOT DO // * See who paid it. A call record carries no value and there is no deposit hook, so the pool // is whatever balance the address holds. `funded()` compares that balance with the declared // allocation and nothing more; it cannot tell you where the STR came from. // * Check that a deposit address belongs to the exchange. It checks the address is well formed // and not this contract. The 72-hour delay is the window in which a person verifies it. // * Call another contract. AresLang has no cross-contract call. // // HOW IT IS TYPICALLY MISUSED // * Funding it before checking the deployed bytecode digest and `owner()`, `treasury()` and // `delay()` against the deployment manifest. Once STR is in, those are the rules it lives by. // * Queuing a release against an unfunded vault and reading the queue as a promise. Solvency is // checked at EXECUTION, because funds may legitimately arrive or be returned in between. // * Expecting to cancel a matured operation. The veto ends at the eta: cancel inside the delay // or not at all. // * Letting a queued operation expire. After `etaOf(id) + grace()` it is dead and must be // queued again, delay and all. // * Rotating the owner's key, or the return address's key, while the vault holds funds. A // retired address can be neither the caller nor the payee of anything; hand the vault over // with `queueOwner` first, or return the funds first. // * Under-declaring the gas limit on the call record. Post-mining covers only // min(gasUsed, gasLimit / 10). MEASURED, on the v2 gas table, with 53-character addresses: // queueDestination 1,978, activateDestination 1,010, queueRelease 1,871, release 3,695 — // of which 2,100 is the `TRANSFER` and 400 the balance read — queueReturn 1,754, // executeReturn 3,565, cancel 575, queueOwner 2,028, acceptOwnership 958. Declare a gas // limit of at least 37,000. // // TO USE IT // Generate the source with `node scripts/exchange-vault.mjs plan`, which writes the eight // constants below from a parameter set and refuses one it cannot write safely; or edit them by // hand. Deploy, verify, fund the address by an ordinary transfer, then queue a destination. // ========================================================================================= contract ExchangeVault { // ---- edit these eight ---------------------------------------------------------------- // Which exchange and which market this pool is for. Read through `exchange()`, `pair()` and // `market()`; checked by nothing, and immutable once deployed. state exchangeName: string = "Example Exchange"; state marketPair: string = "STR/USDT"; state marketId: string = "example-str-usdt"; // The pool this vault is declared to hold, in minor STR (13 decimals). 1,000,000,000 STR. state allocationMinor: uint = 10000000000000000000000; // Who may queue and cancel. `caller` here is the DEPLOYER, so the deployer is the owner unless // this line names an address. state vaultOwner: address = caller; // The one address a return pays. No function changes it. state returnAddress: address = caller; // The waiting period, in milliseconds. 259200000 is 72 hours. Immutable once deployed. state delayMs: uint = 259200000; // How long after the waiting period an operation stays executable. 604800000 is 7 days. state graceMs: uint = 604800000; // ------------------------------------------------------------------------------------- // The active deposit address, "" until the first `activateDestination`. state destination: address = ""; // Ids start at 1 and are never reused. One operation is spread across five maps because // AresLang has no struct; all are written by the queue call and only the status after it. state opCount: uint = 0; // 1 deposit address, 2 release, 3 owner, 4 return. state opKind: map; state opTarget: map; state opAmount: map; state opEta: map; // 0 no such operation, 1 queued, 2 executed, 3 cancelled. state opStatus: map; // At most one deposit-address change and one owner change in flight, so a reader of the queue // never has to work out which of two competing changes will win. state pendingDestinationId: uint = 0; state pendingOwnerId: uint = 0; // Lifetime totals, so the explorer can show where the pool went without replaying the feed. state releasedMinor: uint = 0; state returnedMinor: uint = 0; event DestinationQueued(id: uint, next: address, etaMs: uint); event DestinationActivated(id: uint, previous: address, next: address, by: address); event ReleaseQueued(id: uint, to: address, amount: uint, etaMs: uint); event Released(id: uint, to: address, amount: uint, by: address); event OwnerQueued(id: uint, current: address, next: address, etaMs: uint); event OwnerChanged(id: uint, previous: address, next: address); event Cancelled(id: uint, kind: uint, by: address); event ReturnQueued(id: uint, to: address, amount: uint, etaMs: uint); event Returned(id: uint, to: address, amount: uint, by: address); // ---- views ---------------------------------------------------------------------------- view fn exchange() -> string { return exchangeName; } view fn pair() -> string { return marketPair; } view fn market() -> string { return marketId; } view fn allocation() -> uint { return allocationMinor; } // Read from the native ledger, never mirrored: a cached balance diverges the moment anybody // pays this address without calling it. view fn held() -> uint { return balance(self); } view fn funded() -> bool { return balance(self) >= allocationMinor; } view fn owner() -> address { return vaultOwner; } view fn treasury() -> address { return returnAddress; } view fn delay() -> uint { return delayMs; } view fn grace() -> uint { return graceMs; } view fn activeDestination() -> address { return destination; } view fn isLive() -> bool { return len(destination) > 0; } // What the explorer prints beside the pool. view fn status() -> string { if (len(destination) > 0) { return "live"; } return "integration in progress"; } view fn released() -> uint { return releasedMinor; } view fn returned() -> uint { return returnedMinor; } view fn operationCount() -> uint { return opCount; } view fn kindOf(id: uint) -> uint { return opKind[id]; } view fn targetOf(id: uint) -> address { return opTarget[id]; } view fn amountOf(id: uint) -> uint { return opAmount[id]; } // The earliest block time, in absolute milliseconds, at which this operation may execute. view fn etaOf(id: uint) -> uint { return opEta[id]; } // 0 no such operation, 1 queued, 2 executed, 3 cancelled. A 1 is not "executable": see below. view fn statusOf(id: uint) -> uint { return opStatus[id]; } // Everything the execute step checks except the balance and, for a release, the destination; // read `held()` and `activeDestination()` beside it so "not yet", "not funded" and // "destination changed" stay distinguishable. view fn executable(id: uint) -> bool { return opStatus[id] == 1 && timestamp >= opEta[id] && timestamp <= opEta[id] + graceMs; } view fn expired(id: uint) -> bool { return opStatus[id] == 1 && timestamp > opEta[id] + graceMs; } // The id of the deposit-address change in flight, or 0. An expired one is not in flight. view fn pendingDestination() -> uint { let id = pendingDestinationId; if (id > 0 && opStatus[id] == 1 && timestamp <= opEta[id] + graceMs) { return id; } return 0; } view fn pendingOwner() -> uint { let id = pendingOwnerId; if (id > 0 && opStatus[id] == 1 && timestamp <= opEta[id] + graceMs) { return id; } return 0; } // ---- the deposit address --------------------------------------------------------------- fn queueDestination(next: address) -> uint { require(caller == vaultOwner, "not owner"); require(next != self, "self is not payable"); require(next != destination, "already the active destination"); let pending = pendingDestinationId; require(!(pending > 0 && opStatus[pending] == 1 && timestamp <= opEta[pending] + graceMs), "a destination change is already queued"); let id = opCount + 1; opCount = id; opKind[id] = 1; opTarget[id] = next; opAmount[id] = 0; // From the BLOCK's timestamp, the one clock every node agrees on. opEta[id] = timestamp + delayMs; opStatus[id] = 1; pendingDestinationId = id; emit DestinationQueued(id, next, opEta[id]); return id; } // Open to anybody once the wait is over: the owner decided when it queued. fn activateDestination(id: uint) { require(opKind[id] == 1, "not a destination change"); require(opStatus[id] == 1, "not queued"); require(timestamp >= opEta[id], "still waiting"); require(timestamp <= opEta[id] + graceMs, "operation expired"); opStatus[id] = 2; let previous = destination; destination = opTarget[id]; emit DestinationActivated(id, previous, opTarget[id], caller); } // ---- releases --------------------------------------------------------------------------- // Bound to the deposit address active NOW. Changing it later voids this release. // // `expected` is the payee, restated by the caller. It changes nothing the contract would do — // the release goes to the active destination or nowhere — but it puts the payee INSIDE the // signed call, so a custody room signing a release reads aloud the address it pays, and a // call prepared against one destination cannot be replayed into a vault that has another. fn queueRelease(amount: uint, expected: address) -> uint { require(caller == vaultOwner, "not owner"); require(amount > 0, "amount must be positive"); require(len(destination) > 0, "no destination is active"); require(expected == destination, "not the active destination"); let id = opCount + 1; opCount = id; opKind[id] = 2; opTarget[id] = destination; opAmount[id] = amount; // From the BLOCK's timestamp, the one clock every node agrees on. opEta[id] = timestamp + delayMs; opStatus[id] = 1; emit ReleaseQueued(id, destination, amount, opEta[id]); return id; } // Checks, then effects, then the one interaction. fn release(id: uint) -> uint { require(opKind[id] == 2, "not a release"); require(opStatus[id] == 1, "not queued"); require(timestamp >= opEta[id], "still waiting"); require(timestamp <= opEta[id] + graceMs, "operation expired"); require(opTarget[id] == destination, "destination changed since this release was queued"); let amount = opAmount[id]; require(balance(self) >= amount, "insufficient balance"); opStatus[id] = 2; releasedMinor = releasedMinor + amount; emit Released(id, opTarget[id], amount, caller); transfer(opTarget[id], amount); return amount; } // ---- returns --------------------------------------------------------------------------- // Announced like a release and paid only to the return address, which no argument can name. fn queueReturn(amount: uint) -> uint { require(caller == vaultOwner, "not owner"); require(amount > 0, "amount must be positive"); let id = opCount + 1; opCount = id; opKind[id] = 4; opTarget[id] = returnAddress; opAmount[id] = amount; // From the BLOCK's timestamp, the one clock every node agrees on. opEta[id] = timestamp + delayMs; opStatus[id] = 1; emit ReturnQueued(id, returnAddress, amount, opEta[id]); return id; } // Open to anybody once the wait is over, like a release. fn executeReturn(id: uint) -> uint { require(opKind[id] == 4, "not a return"); require(opStatus[id] == 1, "not queued"); require(timestamp >= opEta[id], "still waiting"); require(timestamp <= opEta[id] + graceMs, "operation expired"); let amount = opAmount[id]; require(balance(self) >= amount, "insufficient balance"); opStatus[id] = 2; returnedMinor = returnedMinor + amount; emit Returned(id, returnAddress, amount, caller); transfer(returnAddress, amount); return amount; } // ---- cancelling and ownership ------------------------------------------------------------- // Any queued operation, inside its delay. After the eta the veto is gone. fn cancel(id: uint) { require(caller == vaultOwner, "not owner"); require(opStatus[id] == 1, "not queued"); require(timestamp < opEta[id], "wait is over"); opStatus[id] = 3; emit Cancelled(id, opKind[id], caller); } fn queueOwner(next: address) -> uint { require(caller == vaultOwner, "not owner"); require(next != self, "a contract cannot own itself"); require(next != vaultOwner, "already the owner"); let pending = pendingOwnerId; require(!(pending > 0 && opStatus[pending] == 1 && timestamp <= opEta[pending] + graceMs), "an owner change is already queued"); let id = opCount + 1; opCount = id; opKind[id] = 3; opTarget[id] = next; opAmount[id] = 0; // From the BLOCK's timestamp, the one clock every node agrees on. opEta[id] = timestamp + delayMs; opStatus[id] = 1; pendingOwnerId = id; emit OwnerQueued(id, vaultOwner, next, opEta[id]); return id; } // Only the named key, which proves it exists, and only after the same delay as everything else. fn acceptOwnership(id: uint) { require(opKind[id] == 3, "not an owner change"); require(caller == opTarget[id], "not the queued owner"); require(opStatus[id] == 1, "not queued"); require(timestamp >= opEta[id], "still waiting"); require(timestamp <= opEta[id] + graceMs, "operation expired"); opStatus[id] = 2; emit OwnerChanged(id, vaultOwner, caller); vaultOwner = caller; } }