This analysis is based on observations of the contract behavior. We are not smart contract security experts. This document aims to explain what the contract appears to do based on the code. It should not be considered a comprehensive security audit or financial advice. Always verify critical information independently and consult with blockchain security professionals for important decisions.
Selectors above were resolved from observed transaction inputs (user functions) or computed from the verified function signatures via cast sig "<signature>" (view / pure functions).
Wraps amount of token (or msg.value for native ETH) for recipient to with a delay-second timelock. Mints an ERC-1155 wrapper at face value of the deposited amount under id (token, delay). If delay == 0, the wrapper goes straight to unlockedBalances[to][id]. If delay > 0, a pendingTransfers[transferId] row is created and transferId is added to _outboundTransfers[msg.sender] and _inboundTransfers[to].
ATTRIBUTE
VALUE
Selector
0x94eeaec9
Parameters
token (zero for ETH), to (recipient), amount (ignored when msg.value != 0), delay (seconds), data (forwarded to _mint's onERC1155Received)
Access
external, payable, permissionless, nonReentrant
Returns
transferId (zero when delay == 0)
FLAG
OBSERVATION
☑
Branchless ETH vs ERC-20 dispatch on msg.value; ERC-20 path uses safeTransferFrom from Solady's SafeTransferLib
☑
Self-deposit blocked: to != address(this). Zero-address recipient blocked: to != address(0)
△
Assumes vanilla ERC-20 semantics — fee-on-transfer or rebasing tokens diverge wrapper supply from reserves
△
Anyone can deposit to any recipient — recipient cannot opt out, and _inboundTransfers[to] grows accordingly
CONDITION
REQUIREMENT
Recipient is not zero
to != address(0) else InvalidRecipient()
Recipient is not the contract
to != address(this) else InvalidDeposit()
If msg.value != 0: token must be zero and amount must be zero
Same as depositTo but additionally posts an ETH tip to SLOWGate. The tip pays whichever keeper lands gate.claim(transferId) after the timelock expires. delay must be non-zero and tip must be non-zero and fit in uint96 (the gate stores tips packed as uint96).
ATTRIBUTE
VALUE
Selector
0x75f92e42
Parameters
token, to, amount, delay, tip (ETH amount, in wei), data
Access
external, payable, permissionless, nonReentrant
Returns
transferId
FLAG
OBSERVATION
☑
msg.value accounting is exact: ETH branch requires msg.value == amount + tip; ERC-20 branch requires msg.value == tip
☑
delay != 0 is enforced — tipped settlement is only meaningful for delayed transfers
☑
tip <= type(uint96).max enforced here; gate's recordTip re-checks defensively
△
If to has a guardian when claimTipped runs later, settlement is blocked — the tip is then refundable to the original tipper once the underlying transfer clears via another path
CONDITION
REQUIREMENT
Recipient is not zero / not this contract
InvalidRecipient() / InvalidDeposit()
amount != 0
else InvalidAmount()
delay != 0
else InvalidDeposit() (no tip without a timelock)
tip != 0 && tip <= type(uint96).max
else InvalidAmount()
Exact msg.value per branch
amount + tip (ETH) or tip (ERC-20) — else InvalidDeposit()
STEP
ACTION
1
Validate recipient and tip preconditions
2
Branch on token: ETH requires msg.value == amount + tip; ERC-20 requires msg.value == tip and safeTransferFrom of amount
3
Delegate to _finishDeposit(token, to, amount, delay, tip, data)
4
Inside _finishDeposit: same as depositTo plus call gate.recordTip{value: tip}(transferId, msg.sender, to)
ERC-1155 wrapper transfer with three modes derived from (guardian on from, delay encoded in id):
No guardian, delay == 0: plain transfer; balance moves to unlockedBalances[to][id].
No guardian, delay > 0: pending transfer created; to cannot move the wrapper for delay seconds.
Guardian set: every transfer requires a matching guardianApproved[from][transferId] entry, regardless of delay.
Caller authorization (msg.sender == from or operator-approved) is enforced by the inherited Solady safeTransferFrom. State changes made before the inherited call roll back if it reverts.
ATTRIBUTE
VALUE
Selector
0xf242432a
Parameters
from, to, id, amount, data
Access
external, nonReentrant, override
FLAG
OBSERVATION
☑
Op-type byte (_OP_TRANSFER = 0) is mixed into the preimage so a transfer-approval cannot be consumed by withdrawFrom
☑
Nonce is bumped only when requiresDelayOrGuardian (delay or guardian present) — plain transfers do not waste a nonce
△
Transferring a delay-encoded id moves the wrapper to a fresh recipient AND restarts the timelock from block.timestamp for the new recipient — passing along an in-flight wrapper hands the recipient a fresh delay
△
to != gate is enforced — direct wrapper transfers to the gate are blocked
Function: withdrawFrom(address from, address to, uint256 id, uint256 amount)
Burns amount of id from from's unlocked balance and pays the raw underlying token (or ETH) to to. If from has a guardian set, requires a matching guardianApproved[from][transferId] entry under op-type _OP_WITHDRAW. Caller authorization (msg.sender == from or operator-approved) is enforced by the inherited Solady _burn(by, from, ...).
ATTRIBUTE
VALUE
Selector
0xd4fdc309
Parameters
from (token holder), to (payout recipient), id (token+delay encoded), amount
Access
external, nonReentrant
FLAG
OBSERVATION
☑
Op-type _OP_WITHDRAW = 1 distinguishes this approval from a transfer approval at the same (from, to, id, amount)
☑
to != gate is enforced — raw underlying cannot be paid into the gate
△
Pre-burn state changes (unlocked-balance decrement, nonce bump, approval delete) all roll back together if the inherited _burn reverts on auth
CONDITION
REQUIREMENT
to != 0, to != this, to != gate
else InvalidRecipient()
amount != 0
else InvalidAmount()
unlockedBalances[from][id] >= amount
else underflow revert
If guardian set: matching approval
else GuardianApprovalRequired()
Caller is from or operator-approved
else NotOwnerNorApproved
STEP
ACTION
1
Decrement unlockedBalances[from][id]
2
If guardian: derive transferId with _OP_WITHDRAW, bump nonce, require approval, delete it
3
Call inherited _burn(msg.sender, from, id, amount) (this is where caller-auth is checked)
4
Pay out: if id's low 160 bits are zero, send ETH; else token.safeTransfer(to, amount)
Function: unlock(uint256 transferId)
After timelock expiry, moves a pending transfer's amount from "pending" into unlockedBalances[pt.to][pt.id]. The wrapper itself stays at pt.to. Subsequent safeTransferFrom of the same id will re-lock it for the new recipient based on the id's encoded delay.
ATTRIBUTE
VALUE
Selector
0x6198e339
Parameters
transferId
Access
nonReentrant; gated to pt.to or any operator approved by pt.to
FLAG
OBSERVATION
☑
Operator-gated to prevent third-party griefers from frontrunning settlement and stranding the sender's clawback path or a keeper's tip on gate.claim
☑
Removes the entry from both in/out sets and deletes pendingTransfers[transferId]
Auto-settle path: after timelock expiry, burns the wrapper from pt.to and pays the raw underlying directly to pt.to. Skips the unlocked-balance step that unlock + withdrawFrom would take. Reverts if pt.to has a guardian set — guarded recipients must use the wrapper-then-withdraw path so the raw exit inherits guardian gating.
ATTRIBUTE
VALUE
Selector
0x379607f5
Parameters
transferId
Access
nonReentrant; gated to pt.to or any operator approved by pt.to
Internal _doClaim uses _burn(address(0), ...) which skips Solady's NotOwnerNorApproved check — every external caller must enforce its own auth (this function does, and so does claimTipped)
△
Code comment notes: any future caller of _doClaim MUST enforce its own auth
Function: claimTipped(uint256 transferId)
Sender-sponsored claim path. Skips the operator-approval check and is callable only by the gate. The gate invokes this only from _claimAndPay for transferIds that carry a posted tip. Guardian veto on pt.to still applies.
ATTRIBUTE
VALUE
Selector
0xe9e2ef0c
Access
nonReentrant; gate-only
CONDITION
REVERT
Caller is not the gate
Unauthorized()
Transfer does not exist
TransferDoesNotExist()
Timelock has not expired
TimelockNotExpired()
Recipient has a guardian
ClaimBlockedByGuardian()
Function: reverse(uint256 transferId)
Cancels a pending transfer before its timelock expires. Returns the wrapper to pt.from and credits unlockedBalances[pt.from][pt.id]. The move uses _safeTransfer, which calls onERC1155Received on pt.from per ERC-1155 spec — contract senders that didn't implement IERC1155Receiver are not reverse-eligible.
ATTRIBUTE
VALUE
Selector
0x97d15425
Access
nonReentrant; gated to pt.from or operator-approved by pt.from
CONDITION
REQUIREMENT
Transfer exists
pt.timestamp != 0
Timelock has not yet expired
block.timestamp < pt.timestamp + (id >> 160) else TimelockExpired()
Caller is pt.from or operator
else Unauthorized()
Function: clawback(uint256 transferId)
Sender recovery for unsettled transfers (e.g. dead/lost recipient). Available 30 days past timelock expiry. Returns the wrapper to pt.from and credits their unlocked balance. Like reverse, _safeTransfer invokes onERC1155Received on pt.from, so contract senders need to implement the receiver interface to be eligible.
ATTRIBUTE
VALUE
Selector
0xfcc36bc9
Access
nonReentrant; gated to pt.from or operator-approved by pt.from
CONDITION
REQUIREMENT
Transfer exists
pt.timestamp != 0
At least delay + 30 days past deposit
block.timestamp >= pt.timestamp + (id >> 160) + 30 days else ClawbackNotReady()
Caller is pt.from or operator
else Unauthorized()
FLAG
OBSERVATION
☑
Wrapper-route only — once unlock or claim runs, settlement to pt.to is final and clawback is no longer reachable
☑
Subsequent raw exit by pt.from goes through withdrawFrom and inherits any guardian gating that pt.from has set
Guardian Functions
Function: setGuardian(address newGuardian)
Arms or rotates the caller's guardian. First-time set (or post-removal set) is immediate. Rotating an already-active guardian stages a pendingGuardian entry with effectiveAt = now + 1 day and emits GuardianChangeProposed. During the window, the user or current guardian can cancel via cancelGuardianChange. Re-proposing the current guardian during the window cancels the in-flight rotation. After the window, only commitGuardian is valid.
ATTRIBUTE
VALUE
Selector
0x8a0dac4a
Access
external, nonReentrant; affects only the caller's row
FLAG
OBSERVATION
☑
Self-guardian blocked (newGuardian != msg.sender) — a stolen key could approve its own outflows
☑
First-time set bumps lastGuardianChange[msg.sender] to invalidate any dangling pre-set approvals
△
After the rotation window, the only ways to abort are: propose a different guardian (restarts the window) and then cancel during the new window — setGuardian(currentGuardian) no longer cancels late
△
Setting newGuardian = address(0) removes the current guardian via the rotation path; this gives the current guardian a 1-day veto window over their own removal
Permissionless poke that applies a staged guardian rotation once effectiveAt has passed. Bumps lastGuardianChange[user], which atomically invalidates any dangling guardian approvals bound to the previous preimage.
ATTRIBUTE
VALUE
Selector
0xa952a15f
Access
external, anyone
CONDITION
REVERT
No rotation pending
NoGuardianChangePending()
block.timestamp < effectiveAt
GuardianChangeNotReady()
Function: cancelGuardianChange(address user)
Vetoes a pending guardian change during the 1-day window. Callable by user or by guardians[user]. After the window, only commitGuardian is valid.
ATTRIBUTE
VALUE
Selector
0xdb6c927d
Access
external, restricted to user or current guardian
FLAG
OBSERVATION
☑
Guardian-side cancel is the protection: defeats a stolen key proposing setGuardian(attacker)
△
A hostile guardian can veto every rotation indefinitely. The author's framing in code comments is "appoint a guardian only if you trust them" — this is the intentional shape of co-sign
Pre-approves a specific transferId for from. Callable only by guardians[from]. The transferId must match the preimage keccak256(from, to, id, amount, nonces[from], lastGuardianChange[from], op-type) for the next safeTransferFrom (op-type 0) or withdrawFrom (op-type 1) the user intends to execute. Use predictTransferId for transfer approvals and predictWithdrawalId for withdraw approvals — the preimages differ.
ATTRIBUTE
VALUE
Selector
0xfa02c4b7
Access
external, guardian-only
FLAG
OBSERVATION
☑
The on-chain op-split prevents cross-op consumption (a transfer approval cannot satisfy a withdraw and vice versa)
△
The op-split does not prevent malicious approval of the wrong op — guardians must still verify intent off-chain. The contract trusts the guardian to validate destination, amount, and op-type before signing
Retracts a previously granted approval. Callable only by guardians[from]. Lets the guardian undo a single mistaken approval without rotating (which would invalidate every dangling approval). Idempotent — revoking a clear slot is a no-op.
ATTRIBUTE
VALUE
Selector
0x47d07c4c
Access
external, guardian-only
View Functions
Function: predictTransferId(from, to, id, amount)
Returns the transferId for the next guardian-gated or delayed safeTransferFrom by from at the current nonces[from] and lastGuardianChange[from]. Uses op-type _OP_TRANSFER. Plain transfers (no delay, no guardian) consume no id — this is the handle for guardian co-sign of wrapper transfers.
Function: predictWithdrawalId(from, to, id, amount)
Same shape but with op-type _OP_WITHDRAW. The op-type byte separates withdraw approvals from transfer approvals at the same (from, to, id, amount).
Returns (true, "") if the transfer exists and the timelock has not expired. Otherwise returns (false, selector) with one of TransferDoesNotExist.selector or TimelockExpired.selector.
Function: isGuardianApprovalNeeded(user, to, id, amount) → bool
true iff guardians[user] != 0 and the precomputed transfer-op transferId is not yet approved.
Function: isWithdrawalApprovalNeeded(user, to, id, amount) → bool
true iff guardians[user] != 0 and the precomputed withdraw-op transferId is not yet approved.
Returns the full set of currently-pending transferIds from the user's outbound or inbound set. The author calls out in code comments that both arrays are unbounded and grow with the set size (_inboundTransfers can be expanded by anyone via dust deposits). On-chain consumers should paginate via *Count + *At(i).
Positional read of a transferId from the corresponding set. EnumerableSetLib swaps with the last element on remove, so positional reads are not stable across settlement. Indexers should snapshot via the array getters or react to events.
Returns the full SLOW dapp HTML reassembled from htmlChunk1 and htmlChunk2 via Solady's SSTORE2.read. Pure metadata — does not affect consensus or fund handling.
Function: name() / symbol() / uri(id)
name() returns "SLOW", symbol() returns "SLOW", uri(id) returns a base64-encoded JSON metadata blob with embedded SVG. uri reads name/symbol of the underlying token via Solady's MetadataReaderLib and clips/escapes them with _utf8Trim and LibString.escapeJSON / escapeHTML to keep the JSON and SVG payloads valid for strict marketplace parsers.
Disabled Function
Function: safeBatchTransferFrom(...)
Always reverts with BatchTransferDisabled(). ERC-1155 batch semantics are not supported. The author's stated rationale in the contract NatSpec is to avoid spamming the inbound/outbound sets via batched zero-amount delayed sends.
Defense-in-depth bound on msg.value: although depositToWithTip already enforces tip <= type(uint96).max, the gate re-checks here so a future caller bypassing the upstream check cannot silently lose value via the uint96 cast
Function: claim(uint256 transferId)
Single-transfer settlement entry point. Delegates to _claimAndPay. If a tip is attached, pays it to msg.sender and routes through slow.claimTipped. If no tip, routes through slow.claim — which requires pt.to to have approved the gate via setApprovalForAll (the auto-claim opt-in).
Function: claimMany(uint256[] transferIds)
Atomic batch settlement. Loops over _claimAndPay for each id. The whole call reverts on the first failure — keepers must filter ids off-chain for expiry, no-guardian status, and pending presence.
Function: refundTip(uint256 transferId)
Recovers an unclaimed tip after the underlying transfer cleared by a non-gate path (unlock, reverse, clawback, or recipient-direct claim). Callable only by the original tip sender. Reverts while the transfer is still pending (the gate reads slow.pendingTransfers(transferId) and requires ts == 0).
ATTRIBUTE
VALUE
Access
external, restricted to original tipper
CONDITION
REVERT
Pending transfer still exists
TipStillPending()
No tip recorded for this id
NoTip()
Caller is not the original tipper
Unauthorized()
Lifecycle Diagram — A Pending Transfer
sequenceDiagram
autonumber
actor Sender
participant SLOW
participant Gate
participant Token
actor Recipient
actor Keeper
Sender->>SLOW: depositToWithTip(token, Recipient, amt, delay, tip, data)
SLOW->>Token: safeTransferFrom(Sender, SLOW, amt) — ERC-20 only
SLOW->>SLOW: mint wrapper id to Recipient
SLOW->>SLOW: write pendingTransfers entry
SLOW->>Gate: recordTip(transferId, Sender, Recipient) with tip value
SLOW-->>Sender: emit TransferPending
Note over SLOW,Recipient: timelock window — Sender may reverse, Recipient waits
alt Sender reverses before expiry
Sender->>SLOW: reverse(transferId)
SLOW->>SLOW: credit unlocked balance to Sender, delete pending
SLOW-->>Sender: emit TransferReversed
Sender->>Gate: refundTip(transferId)
Gate-->>Sender: tip refund
else Recipient settles directly after expiry
Recipient->>SLOW: claim(transferId) or unlock + withdrawFrom
SLOW->>Token: safeTransfer(Recipient, amt)
SLOW-->>Recipient: emit TransferClaimed
Sender->>Gate: refundTip(transferId)
Gate-->>Sender: tip refund
else Keeper settles via gate after expiry
Keeper->>Gate: claim(transferId)
Gate->>SLOW: claimTipped(transferId)
SLOW->>Token: safeTransfer(Recipient, amt)
Gate->>Keeper: tip payout
SLOW-->>Recipient: emit TransferClaimed
Gate-->>Keeper: emit TipPaid
else Recipient never settles after 30 day grace
Sender->>SLOW: clawback(transferId)
SLOW->>SLOW: credit unlocked balance to Sender, delete pending
SLOW-->>Sender: emit TransferClawedBack
Sender->>Gate: refundTip(transferId)
Gate-->>Sender: tip refund
end
Decision Tree — setGuardian(newGuardian)
The same entry point covers four distinct behaviors depending on the current state of guardians[msg.sender] and pendingGuardian[msg.sender]. The branches below are the load-bearing observation: a single call shape (setGuardian) is doing first-time set, removal-staging, rotation-staging, AND in-window cancel.
flowchart TD
Start(["msg.sender calls setGuardian"])
Start --> Self{"newGuardian == msg.sender?"}
Self -->|yes| RevertSelf["revert InvalidGuardian"]
Self -->|no| Same{"newGuardian == current guardian?"}
Same -->|yes| InWindow{"rotation pending<br/>and still before effectiveAt?"}
InWindow -->|yes| CancelInWindow["delete pendingGuardian<br/>emit GuardianChangeCanceled<br/>return"]
InWindow -->|no| Noop["return — no-op"]
Same -->|no| HasGuardian{"current guardian set?"}
HasGuardian -->|no| Immediate["guardians := newGuardian<br/>lastGuardianChange := now<br/>emit GuardianSet<br/>takes effect immediately"]
HasGuardian -->|yes| Stage["stage pendingGuardian<br/>effectiveAt := now + 1 day<br/>emit GuardianChangeProposed<br/>commit allowed after delay"]
Stage -.->|veto path during window| VetoNote["user OR current guardian<br/>can cancelGuardianChange<br/>before effectiveAt"]
Stage -.->|hostile-takeover defense| TakeoverNote["stolen key cannot remove<br/>the guardian instantly —<br/>guardian sees the proposal<br/>and cancels during the window"]
style RevertSelf fill:#ffe1e1
style CancelInWindow fill:#fff4e1
style Immediate fill:#e1f5ff
style Stage fill:#e1f5ff
Two non-obvious consequences fall out of this shape:
Setting newGuardian = address(0) (removal) takes the rotation path while a guardian is active, so removal also costs a 1-day window — and the current guardian can veto their own removal during that window. That asymmetry is what the contract trades for protection against stolen-key removals.
Once block.timestamp >= effectiveAt, the in-window cancel branch (Same → InWindow → yes) is unreachable — setGuardian(currentGuardian) becomes the no-op path, and the only valid action on the pending entry is commitGuardian (callable by anyone). To abort late, the user must propose a different guardian to restart the window, then cancel during the new window.