feat(escrow): hold value against a platform handle nobody has claimed yet - #5
Open
SupremaLex wants to merge 1 commit into
Open
SupremaLex wants to merge 1 commit into
SupremaLex wants to merge 1 commit into
Conversation
The app must let somebody send to `@alice` before `@alice` exists on chain. The
Bank cannot: its escrow is keyed by an account's immutable id, and a sender who
knows only a handle has no id to key on. So `HandleEscrow` keys by the handle.
A NEW contract, not a change to the Bank diamond. Two state-changing functions
and one mapping do not warrant `LibDiamond`, a cut/loupe/ownership facet set, a
`BankInit` and hand-rolled modifiers, and the point of this contract is that it
is not part of the transfer stack. It takes the `IdentityNames` shape instead:
UUPS behind `ERC1967Proxy`, `Ownable2Step`, the transient reentrancy guard.
**The escrow is for the window before a handle is claimed, and only that.** A
deposit for a handle that already resolves is paid STRAIGHT THROUGH to its
holder -- once an account has claimed its identity, holding the value would add
a claim transaction and reach the same wallet. `Forwarded` and `Deposited` are
separate events because an indexer must tell "this is waiting" from "this was
delivered", and no balance changed for it to read. What `Forwarded` reports is
what the holder GAINED, measured across the transfer, so a fee-on-transfer
token cannot leave the only record of a payment overstating it.
That makes a deposit depend on the recipient: a holder that cannot receive value
fails the call. It is the honest outcome -- the sender learns, instead of the
value waiting in a slot only that same wallet could ever claim.
The race is accepted: one calldata has two outcomes depending on whether it
lands before or after a `bind` in the same block. Both deliver to the holder of
the handle, so the difference is one transaction, not one of destination.
**`claim` checks the destination, not only the caller.** The zero address
ACCEPTS a native transfer, so an unset recipient would empty the slot, burn the
value and log a success; this contract's own address would empty the books and
leave the value here as surplus nothing points at. Neither is recoverable --
there is no refund and no owner lever -- so both are refused.
**The handle is the whole key. There is no deadline and no refund.** That was
decided deliberately and the consequences are written down rather than left to
be discovered, each pinned by a test named as intent:
* a platform that recycles a handle hands the new holder whatever accumulated
for the previous one;
* after a rename the slot waits for whoever proves the freed handle next,
which may be a different account;
* neither the depositor nor the owner can take a deposit back.
**What the escrow keys on is NOT what it validates against.** The slot comes
from a rules-independent form of the text -- trim spaces, drop one leading
at-sign, fold A-Z -- so the owner reconfiguring a platform's normalization
cannot move money already held. The text is separately checked against the
platform's CURRENT rules on the way in, so nothing funds a slot no proof could
ever claim.
Immunity from re-keying is not immunity from stranding, and the header says so:
a claim is authorized by `resolveHandle`, which normalizes under the rules of
the moment, so an owner who narrows a platform makes the affected handles
resolve to nobody and their value waits, at the same key, until compatible
rules return. A test narrows X after a deposit and pins both halves.
The at-sign is DROPPED rather than refused, exactly as `stripLeadingAt` does.
That is safe for every expressible `Rules` -- a non-email platform refuses
`0x40` outright and an email needs a name part before its `@` -- so no platform
can accept a handle beginning with one, and both spellings reach a single slot
instead of splitting one identity's money across two keys. The invariant that
keeps this true is recorded where the folding happens.
The validation needs the rules, so `IdentityNames` gains `rulesOf`. Without it
the escrow either accepts text that can never resolve -- and there is no refund
-- or keeps its own copy of the rules and disagrees with the naming system the
first time `setPlatform` runs. `resolveHandle` cannot answer the question: it
returns the zero address both for a handle nobody holds and for text nobody
could hold, and those two must not be confused when money is about to move.
The transform, unlike the rules, is PINNED here at compile time. That is what
makes the key independent of configuration, and it means the naming system
cannot change its normalizer alone: such an upgrade has to carry an upgrade
here, and a `Rules` field appended there would be dropped by this decoder. Said
in the header rather than left to be discovered.
The lemma is checked across all 44 rows of `HandleVectors`, with no row
skipped, and the slot derivation is pinned by a literal computed from the
formula rather than read back out of the contract -- Python and Rust both
reproduce it.
Claim is authorized by `resolveHandle(platformId, handle) == msg.sender` and
nothing else, drains one token's slot, and pays a recipient the claimer names.
Copying the calldata gains nothing, because the check is against the caller.
The rules are not re-checked on the way out: text that no longer normalizes
resolves to nobody, so the holder check already refuses it, and re-checking
would only replace an honest `NotTheHolder` with a misleading `UnusableHandle`.
**The naming system's owner is part of this contract's trust base**, and the
header now says so instead of claiming the owner cannot reach a balance.
Authorization is `resolveHandle` and nothing else, so whoever decides what that
answers can take what is held: `setVerifier` installs a verifier, a `bind`
through it makes the owner the holder of any handle, and `claim` pays it. Two
ordinary transactions, no upgrade and no proxy event. It adds no new party --
the same key already decides which proofs mint names at all -- but pretending
it does not exist would be worse than naming it.
Fee-on-transfer safety: an escrowed deposit credits the balance the contract
actually gained, so the books never promise more than it holds.
No pause. A pause on `claim` freezes other people's money behind an owner key;
the emergency lever is the upgrade, which is already visible.
Every guard is proven red without its code: deposit not checking the text
against the platform's rules, the at-sign kept instead of folded, the amount
asked for credited instead of the balance gained, no reentrancy guard on
deposit, claim paying before it zeroes the slot, claim not checking the caller
holds the handle, claim not checking the destination, a token deposit carrying
ether, native value not equal to the amount, a held handle escrowing instead of
paying through, the ERC-20 payout going nowhere, `Forwarded` reporting the
amount asked for, and the storage root reordered under an upgrade.
The reentrancy tests reenter with calls that would SUCCEED -- a token that
deposits again from inside its own transfer, and a claimer that claims again
from inside its payout while somebody else's escrow sits alongside -- and
expect the guard's own error rather than any revert. The upgrade test upgrades
to a version that APPENDS a field and reads the old ones back through the new
layout, so a reordered field fails it; a byte-identical upgrade could not.
Downstream: `Deploy.s.sol`, the vendored artifacts and `COVERED`, a new
`bindings/escrow.rs`, the TypeScript ABI, and an anvil test that deploys the
escrow, watches two spellings of one handle accumulate in a single slot and
requires the authorization refusal by name. The mock verifier deliberately does
NOT ship in the crate's artifacts: it reports whatever a caller stages, so a
copy reachable from a deploy tool is a way to mint any identity on a live chain.
Assisted-by: Claude Opus 5
Signed-off-by: SupremaLex <georglutsenko@gmail.com>
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The app must let somebody send to
@alicebefore@aliceexists on chain. TheBank cannot: its escrow is keyed by an account's immutable id, and a sender who
knows only a handle has no id to key on. So
HandleEscrowkeys by the handle.A new contract, not a change to the Bank diamond. Two state-changing
functions and one mapping do not warrant
LibDiamond, a facet set, aBankInitand hand-rolled modifiers — and the point of this contract is that it is not
part of the transfer stack. It takes the
IdentityNamesshape: UUPS behindERC1967Proxy,Ownable2Step, the transient reentrancy guard.The escrow is for one window only
A deposit for a handle that already resolves is paid straight through to its
holder. Once an account has claimed its identity, holding the value would only
add a claim transaction to reach the same wallet.
DepositedandForwardedare separate events because an indexer must tell"this is waiting" from "this was delivered", and no balance changed for it to
read.
Forwardedreports what the holder gained, measured across thetransfer, so a fee-on-transfer token cannot leave the only record of a payment
overstating it.
Paying through makes a deposit depend on the recipient: a holder that cannot
receive value fails the call. That is the honest outcome — the sender learns,
instead of the value waiting in a slot only that same wallet could ever claim.
Decisions, and the consequences written down
The handle is the whole key. No deadline and no refund. Each consequence is
pinned by a test named as intent, not left to be discovered:
for the previous one;
may be a different account;
The key, which is the load-bearing part
The slot comes from a rules-independent form of the text — trim spaces, drop
one leading at-sign, fold A–Z — so an owner reconfiguring a platform's
normalization cannot move money already held. The text is separately
validated against the platform's current rules on the way in, so nothing funds a
slot no proof could ever claim.
Immunity from re-keying is not immunity from stranding, and the header says
so: a claim is authorized by
resolveHandle, which normalizes under the rules ofthe moment, so an owner who narrows a platform makes the affected handles resolve
to nobody and their value waits, at the same key, until compatible rules return.
A test narrows X after a deposit and pins both halves.
The at-sign is dropped, exactly as
stripLeadingAtdoes, rather thanrefused. That is safe for every expressible
Rules— a non-email platformrefuses
0x40outright, an email needs a name part before its@— so noplatform can accept a handle beginning with one, and both spellings reach a
single slot instead of splitting one identity's money across two keys. The lemma
is checked across all 44 rows of
HandleVectorswith no row skipped, and theslot derivation is pinned by a literal computed from the formula rather than read
back out of the contract; Python and Rust both reproduce it.
The validation needs the rules, so
IdentityNamesgainsrulesOf. Without itthe escrow either accepts text that can never resolve — and there is no refund —
or keeps its own copy of the rules and disagrees with the naming system the first
time
setPlatformruns.resolveHandlecannot answer the question: it returnsthe zero address both for a handle nobody holds and for text nobody could hold,
and those two must not be confused when money is about to move.
The transform, unlike the rules, is pinned here at compile time. That is what
makes the key independent of configuration, and it means the naming system cannot
change its normalizer alone: such an upgrade has to carry an upgrade here, and a
Rulesfield appended there would be dropped by this decoder.What review changed
An xhigh review found a real fund-loss bug and two false claims in the contract's
own comments. Worth reading closely, since the fixes shaped the design:
claimvalidated the caller and never the destination. The zero addressaccepts a native transfer, so an unset recipient emptied the slot, burned the
value and logged a success; this contract's own address emptied the books and
left the value as surplus nothing points at. Neither is recoverable. Both are
now refused.
owner installs a verifier it controls, binds any handle to itself and claims —
two ordinary transactions, no upgrade, no proxy event. Not removable without
the escrow ceasing to follow the naming system, so the header now states that
the naming owner is part of this contract's trust base and that this is larger
than a pause rather than a substitute for one.
Testing
870 tests. Thirteen guards proven red without their code, including claim not
checking the destination, the at-sign kept instead of folded, the ERC-20 payout
going nowhere,
Forwardedreporting the requested amount, and the storage rootreordered under an upgrade.
The reentrancy tests reenter with calls that would succeed — a token that
deposits again from inside its own transfer, and a claimer that claims again from
inside its payout while somebody else's escrow sits alongside — and expect the
guard's own error rather than any revert. The upgrade test upgrades to a version
that appends a field and reads the old ones back through the new layout, so a
reordered field fails it; the byte-identical upgrade it replaced could not.
Notes
The mock verifier deliberately does not ship in the crate's artifacts: it
reports whatever a caller stages, so a copy reachable from a deploy tool is a way
to mint any identity on a live chain. The full deposit → bind → claim path is
covered in Solidity; the anvil test covers what Rust owns — the artifact deploys,
the binding shapes, the slot derivation, and the authorization refusal by name.
This branch touches
IdentityNames.sol,Deploy.s.sol,anvil.rs,artifacts.rs,vendor-artifacts.shandcodegen.mjs, all of whichfeat/identity-bind-fee(#4) also touches. Whichever merges second needs arebase and a regeneration of the ABI and vendored artifacts.