A digest, a sequence number and a block height for each owner and space.
EpochAnchor is the one contract under kint. It keeps the head of each owner's chain of epochs per space; the ciphertext rides in the calldata of push and never touches contract storage.
The deployment#
| field | value |
|---|---|
| address | 0xa22E03f7a4145Bf4909a83595C90a38E14d79600 |
| chain | Base mainnet, chain id 8453 |
| deploy tx | 0xf2e03cd1c4e7ca005ee602a6c61ed50861d2238acee217b2c19f8670ba2cc9f6 |
| deploy block | 51081696 (Sep 9 2026) |
| deployer | 0xC635e6Eb223aE14143E23cEEa9440bC773dc87Ec |
| deploy receipt | gasUsed 885,843; effective gas price 7,833,683 wei; L1 fee 1,526,707,829 wei |
| compiler | solc 0.8.28, evm cancun, optimizer 200 runs, bytecode_hash none |
| source | verified on Basescan |
The contract has no owner, no admin, no pause, no proxy, no upgrade path, no constructor arguments, no receive, no fallback, and it never handles ETH. Its only dependency is forge-std, and only for tests; the ECDSA recovery is inline. A change to the contract means a redeploy, which is a new address and a new chain of epochs that starts empty.
kint reads the address from KINT_CONTRACT and falls back to the address above (DEFAULT_CONTRACT in src/kint/chain.py). The browser core exports the same default as DEFAULT_CONTRACT in js/src/chain.ts, and its read functions take the contract address as a parameter. The ABI ships in three identical copies: contracts/abi/EpochAnchor.json, the abi field of contracts/deployments/base-mainnet.json, and src/kint/EpochAnchor.abi.json inside the Python package.
State#
Four public mappings, each with a generated getter:
| mapping | key | value |
|---|---|---|
head | (address owner, bytes32 space) | Head { bytes32 digest; uint64 seq; uint64 blockNumber; } |
sessionKeyExpiry | (address owner, address key) | uint64 expiry, a unix timestamp; 0 means revoked or never set |
startSeq | (address owner, bytes32 space) | uint64 seq, a reader hint; it does not affect push |
authNonce | address owner | uint256 nonce, the replay counter for setSessionKeyBySig |
The head record#
head[owner][space] is the newest epoch for that owner and space. digest is keccak256 of its ciphertext, seq is its 1-based sequence number, and blockNumber is the block it landed in. A space nobody has written to returns all zeros, so the first push passes prev = bytes32(0) and gets seq 1.
A space is a bytes32 that kint derives from the tenant id: keccak256("kint-space-v1" || tenant_id) over the UTF-8 bytes, with no separator (space_id in src/kint/crypto.py). Two tenants under the same owner have independent heads, and so do two owners using the same tenant id.
Functions#
| function | who may call | what it does |
|---|---|---|
push(address owner, bytes32 space, bytes32 prev, bytes ct) returns (bytes32 digest, uint64 seq) | the owner, or a session key whose expiry is still ahead | Appends one epoch. Reverts unless canWrite(owner, msg.sender), then unless ct is non-empty, then unless prev equals the current head digest. Stores keccak256(ct), seq + 1 and block.number; emits Epoch. |
setSessionKey(address key, uint64 expiry) | anyone, for their own address | Sets sessionKeyExpiry[msg.sender][key] = expiry (0 revokes). Increments authNonce[msg.sender]. Emits SessionKeySet. |
revokeSessionKey(address key) | anyone, for their own address | Sets the key's expiry to 0 in this same block. Increments authNonce[msg.sender]. Emits SessionKeySet with expiry 0. |
setSessionKeyBySig(address owner, address key, uint64 expiry, uint256 deadline, bytes signature) | anyone who holds a valid owner signature; normally the session key itself | Checks deadline, then the owner's EIP-712 signature over the current nonce. On success sets the expiry, sets authNonce[owner] to nonce + 1, emits SessionKeySet. |
setStartSeq(bytes32 space, uint64 seq) | anyone, for their own address | Records where a reader should start walking msg.sender's chain in space. Monotone non-decreasing; setting the same value again is allowed. Emits StartSeqSet. |
canWrite(address owner, address writer) view returns (bool) | anyone | True when writer == owner, or when sessionKeyExpiry[owner][writer] > block.timestamp. |
DOMAIN_SEPARATOR() view returns (bytes32) | anyone | The EIP-712 domain separator, computed at call time from block.chainid and address(this). |
authorizationDigest(address owner, address key, uint64 expiry, uint256 nonce, uint256 deadline) view returns (bytes32) | anyone | The exact digest an owner signs to authorize a session key, so a client never rebuilds the domain by hand. |
head, sessionKeyExpiry, startSeq, authNonce | anyone | The getters for the four mappings above. |
The order of checks in push matters when you read a revert: authorization first, then the empty payload, then the head comparison. The compare against prev is a cheap compare-and-swap. Two machines racing on the same head cannot both land, and the loser gets StaleHead with the digest it should have built on.
kint rarely gets that far. Before it sends, kint push compares the chain head with the head this machine last saw, anchored or pulled; when they differ it stops, says another machine pushed, and tells you to run kint pull first. The one exception is the owner's way past an epoch that never opens: after a pull that stopped at such an epoch, kint compact --over-skipped (or kint rekey --over-skipped) builds its snapshot on the chain head's digest instead of this machine's, which is exactly the prev that push checks (Epochs on Base). It then checks canWrite for its session key and stops with session key ... is not authorized for owner ...: authorize it (kint authorize) and fund it. Every send is gas-estimated first, so a push that would still revert fails as transaction would revert: ... without spending gas.
No kint command sends setStartSeq in v0.3.0. The browser core exposes a reader for it (readStartSeq in js/src/chain.ts, documented there as the lowest seq the owner still vouches for).
Events#
| event | indexed | data |
|---|---|---|
Epoch | address owner, bytes32 space, address writer | uint64 seq, bytes32 prev, bytes32 digest, uint64 prevBlock |
SessionKeySet | address owner, address key | uint64 expiry |
StartSeqSet | address owner, bytes32 space | uint64 seq |
writer in Epoch is msg.sender: the owner itself or the session key that pushed. prevBlock is the block of the previous head (0 for the first epoch in a space). It lets a reader walk from the head back to genesis with one exact-block eth_getLogs per epoch, filtered on the Epoch topic, the owner and the space, so no RPC log-range cap is ever hit (walk_epochs in src/kint/chain.py, walkEpochs in js/src/chain.ts).
The ciphertext is in no event. A reader fetches it from the push transaction's calldata with eth_getTransactionByHash, checks that the transaction is addressed to EpochAnchor and calls push, and checks that keccak256 of the decoded ct equals the event's digest.
Errors#
| error | raised by | when |
|---|---|---|
NotAuthorized() | push | canWrite(owner, msg.sender) is false: a stranger, or a session key that is revoked, expired or never set. |
EmptyCiphertext() | push | ct is zero bytes long. |
StaleHead(bytes32 expected, bytes32 got) | push | prev is not the current head digest. expected is the digest on chain, got is the prev you sent. |
AuthorizationExpired() | setSessionKeyBySig | block.timestamp > deadline. Checked before anything else. |
BadSignature() | setSessionKeyBySig | The signature does not verify for the current nonce. The signature setSessionKeyBySig checks lists every case. |
SeqNotMonotone() | setStartSeq | seq is lower than the value already stored. |
Selectors and topics#
Computed from the signatures with cast sig and cast sig-event. Use them to name a raw revert or to filter logs by hand.
| signature | selector or topic0 |
|---|---|
push(address,bytes32,bytes32,bytes) | 0xa2483825 |
setSessionKey(address,uint64) | 0x580da310 |
revokeSessionKey(address) | 0x84f4fc6a |
setSessionKeyBySig(address,address,uint64,uint256,bytes) | 0x5247dcff |
setStartSeq(bytes32,uint64) | 0x15e2058b |
head(address,bytes32) | 0x24e8393d |
canWrite(address,address) | 0xedf2c387 |
sessionKeyExpiry(address,address) | 0x6844536f |
startSeq(address,bytes32) | 0x1408deb9 |
authNonce(address) | 0x8b524b7a |
DOMAIN_SEPARATOR() | 0x3644e515 |
authorizationDigest(address,address,uint64,uint256,uint256) | 0x09c9881f |
NotAuthorized() | 0xea8e4eb5 |
StaleHead(bytes32,bytes32) | 0x7fe83496 |
BadSignature() | 0x5cd5d233 |
AuthorizationExpired() | 0x0f05f5bf |
SeqNotMonotone() | 0x4863002e |
EmptyCiphertext() | 0xdbe0cd78 |
Epoch(address,bytes32,address,uint64,bytes32,bytes32,uint64) | 0x86da9d02e15b0a4ed66c7fca89e82bdb54f4f39993c59a4c9a5256f0eedf86bc |
SessionKeySet(address,address,uint64) | 0x0775f7c38a29f43cd7fbc9f49b6e6ce64501452fd077c83ed52fa3e847da5625 |
StartSeqSet(address,bytes32,uint64) | 0x429767075b5fba88ebcab612058dbfc8613825fa68a59fa886f002c46636fa3e |
Session keys#
A session key is a plain EOA, one per machine, that appends under the owner's name. The owner authorizes it once with an expiry. Until that timestamp is reached, the key may call push for any space of that owner; after it, the key gets NotAuthorized. Because canWrite compares with a strict >, a key stops working in the second its expiry is reached. revokeSessionKey ends it at once, in the same block. A session key can decrypt nothing; Keys and custody covers how custody splits between the two keys.
kint authorize payload, page and direct set the expiry to now plus --days (default 30) days, and submit sends the expiry payload printed. The five actions reach the contract like this:
kint authorize payloadprints the EIP-712 typed data for the owner to sign, with the currentauthNonce(owner)and a deadline 300 seconds out.kint authorize submit --deadline <d> --expiry <e> --signature -then sendssetSessionKeyBySigfrom the session key, which pays the gas.--signature -reads the signature from stdin; a path is read and then deleted.kint authorize pageserves a one-shot loopback page for a Base Account owner, and the account sendssetSessionKey(key, expiry)itself. kint then pollscanWrite(owner, key)until the key shows up.kint authorize directsendssetSessionKeyfrom an owner key read fromKINT_OWNER_KEY(env only, never argv), and refuses when that key is not the owner.kint authorize burn-noncere-sendssetSessionKeywith the key's current expiry, from the account inKINT_OWNER_KEY, which only consumes the nonce. Likedirect, it refuses when that account is not the owner.
kint session-key show prints the key's address, keystore path, balance, and, when an owner is known, authorized (from canWrite) and expiry (from sessionKeyExpiry). kint session-key rotate is local only: it moves the old keystore aside to session.key.old and creates a new key. The old key stays authorized on chain until its expiry. kint ships no command that sends revokeSessionKey, and the authorize page sends only setSessionKey, so to cut an old key off early the owner sends revokeSessionKey(key) (or setSessionKey(key, 0)) from their own wallet.
The signature setSessionKeyBySig checks#
The domain is name "kint EpochAnchor", version "1", chainId, verifyingContract. The signed type is:
SessionKeyAuthorization(address owner,address key,uint64 expiry,uint256 nonce,uint256 deadline)The contract reads nonce from authNonce[owner], so the caller never passes it. This is a different EIP-712 domain from the vault message a wallet signs to derive the data key, so an authorization signature can never double as a derive signature. Because the separator is rebuilt from block.chainid on every call, a signature made for chain 8453 does not verify on a chain with another id, even one forked from Base.
Checking splits on whether the owner address has code:
- Deployed contract account (
owner.code.length > 0). The contract calls ERC-1271isValidSignature(digest, signature)on the owner and requires the magic value0x1626ba7e. Any otherbytes4, or a revert inside the account, isBadSignature. A reply that does not decode as abytes4(too short, say) reverts without an error name instead, because Solidity'stry/catchdoes not catch a failure to decode the return data. - No code (an EOA, or a smart account that is not deployed yet). The signature must be exactly 65 bytes laid out as
r || s || v. Avof 0 or 1 is normalised to 27 or 28, and any othervis rejected. Ansabove half the secp256k1 order is rejected, soshas one encoding.ecrecovermust return the owner and never the zero address. Every failure isBadSignature.
A signature that already went through is rejected on replay, because the nonce it signed over has moved on. That is also BadSignature, not a separate error.
Warning. A counterfactual smart account cannot use
setSessionKeyBySig. A Base Account that has never sent a transaction has no code, so the contract takes the ecrecover path and rejects its signature, and ERC-6492 wrapped signatures are deliberately not supported. The account must sendsetSessionKey(key, expiry)itself: that one transaction deploys the account and authorizes the key. After that the account has code, andsetSessionKeyBySigworks for every later key.kint authorize pagedoes exactly this.
The live Base Account 0xd3390EDAC3d0EB41248C132792B0DcC5f0b0D4E3 went through that path on Sep 9 2026. Its own setSessionKey transaction, sent from kint's authorize page, deployed it, and its machine then anchored an epoch (tx, block 51082012). The fork suite checks both sides: an address with no code falls through to ecrecover and fails, and the now-deployed account takes the ERC-1271 path.
Why owner actions consume the nonce#
setSessionKey and revokeSessionKey both increment authNonce[msg.sender], even though neither checks a signature. The point is revocation. Suppose the owner signs a long authorization for a machine, the machine holds it back without submitting it, and the owner later revokes that machine's key. Without the increment, the machine could submit the withheld signature and re-arm itself. With it, any signed-but-unsubmitted authorization dies the moment the owner acts on chain. The unit tests test_RevokeInvalidatesUnspentAuthorization and test_SetSessionKeyInvalidatesUnspentAuthorization pin this down. kint authorize burn-nonce exists for when you want that cancellation without changing any key.
Reading the head with cast#
Every read is a free eth_call, and nothing below needs a key. The space is the keccak of the literal string kint-space-v1 followed by the tenant id, which cast keccak computes from the string's bytes.
For any owner and tenant:
OWNER=0xYourOwnerAddress
TENANT=your-tenant-id
cast call 0xa22E03f7a4145Bf4909a83595C90a38E14d79600 \
"head(address,bytes32)(bytes32,uint64,uint64)" \
"$OWNER" "$(cast keccak "kint-space-v1$TENANT")" \
--rpc-url https://mainnet.base.orgFor the demo tenant kint-demo, cast keccak "kint-space-v1kint-demo" returns 0xbd2a3b5b8f3fb4c81658d863b90018f1af511c28b33356fc81c2de406be98cb0. Its first 16 hex characters are the space bd2a3b5b8f3fb4c8 that kint status prints. With the demo owner:
cast call 0xa22E03f7a4145Bf4909a83595C90a38E14d79600 \
"head(address,bytes32)(bytes32,uint64,uint64)" \
0xC635e6Eb223aE14143E23cEEa9440bC773dc87Ec \
0xbd2a3b5b8f3fb4c81658d863b90018f1af511c28b33356fc81c2de406be98cb0 \
--rpc-url https://mainnet.base.org0x7df5378a8c4fa8664365cf8956033cac2f4e3cbf7a4767a377d759b262f01a13
2
51081880The three values come back in order: the head digest, its seq, and the block it landed in (some cast versions append a scientific-notation hint to the block, such as [5.108e7]). This is epoch 2 of the demo memory, whose ciphertext is the calldata of 0x369907fb…. If the demo anchors more epochs, the seq and block rise. Read the block as an upper bound: the head existed no later than that block. kint status prints the same head on its chain head: line (the seq, the first 16 hex characters of the digest, the block, whether this machine is in step, and the contract address).
The other reads take the same shape:
KEY=0xSessionKeyAddress
SPACE="$(cast keccak "kint-space-v1$TENANT")"
cast call 0xa22E03f7a4145Bf4909a83595C90a38E14d79600 "canWrite(address,address)(bool)" "$OWNER" "$KEY" --rpc-url https://mainnet.base.org
cast call 0xa22E03f7a4145Bf4909a83595C90a38E14d79600 "sessionKeyExpiry(address,address)(uint64)" "$OWNER" "$KEY" --rpc-url https://mainnet.base.org
cast call 0xa22E03f7a4145Bf4909a83595C90a38E14d79600 "authNonce(address)(uint256)" "$OWNER" --rpc-url https://mainnet.base.org
cast call 0xa22E03f7a4145Bf4909a83595C90a38E14d79600 "startSeq(address,bytes32)(uint64)" "$OWNER" "$SPACE" --rpc-url https://mainnet.base.orgGas#
The contract's own execution is flat and small. contracts/README.md records 61,409 gas for a 4 KB epoch and 139,387 for a 100 KB one (storage write, event, keccak); test_GasForFourKilobyteAndHundredKilobytePushes logs that execution-only delta. That is not the bill. Since EIP-7623 (live on Base since the Isthmus upgrade), a transaction is charged max(standard, 21000 + 10 * (zeros + 4 * nonzeros)). An AES-GCM ciphertext is almost all nonzero bytes, so that works out to 40 gas per byte, and the floor absorbs execution at every bucket. Measured on a Base fork at block 51,081,018, the receipt's gasUsed equals the floor exactly:
| bucket | calldata bytes | gasUsed |
|---|---|---|
| 4 KB | 4,356 | 191,040 |
| 8 KB | 8,452 | 355,390 |
| 16 KB | 16,644 | 681,840 |
| 32 KB | 33,028 | 1,336,090 |
| 64 KB | 65,796 | 2,642,640 |
| 96 KB | 98,564 | 3,949,670 |
Base charges calldata a second time, as the L1 data fee in the receipt's l1Fee. A 4 KB epoch costs about 0.000002 ETH on Base all in; the exact figure moves with the base fee (contracts/README.md works it out at about 0.000001 ETH at a 0.005 gwei base fee). kint records the real gasUsed, effectiveGasPrice and l1Fee from every receipt. Deploying costs 879,343 gas on a Base fork, and the mainnet deploy receipt shows 885,843. Restoring and reading never send a transaction. Epochs on Base covers what goes into the calldata.
Deploying your own copy#
You do not need to: every owner uses the one deployment, and its heads are keyed by owner. If you want your own, build first so the artifact exists, then run the Python helper from the repository root:
(cd contracts && forge build)
export KINT_RPC_URL=... # a Base mainnet endpoint
export KINT_DEPLOYER_KEY=0x... # env only, never a flag, never printed
env -u PYTHONPATH .venv/bin/python contracts/deploy.pyIt stops when either variable is unset, when the build artifact is missing, when the RPC cannot be reached or reports a chain other than 8453 (wrong chain: RPC reports N, expected 8453), or when the deployer has no ETH. It estimates gas, adds 20 percent, sends an EIP-1559 transaction, prints the hash, address, gasUsed, effectiveGasPrice and the Base l1Fee, and overwrites contracts/deployments/base-mainnet.json with the address, transaction hash, block number, chain id, deployer and ABI. The forge equivalent, run from contracts/, is forge script script/Deploy.s.sol:Deploy --rpc-url "$KINT_RPC_URL" --broadcast plus a signer flag such as --ledger. Point kint at the new address with KINT_CONTRACT (Configuration); epochs anchored at the old address stay there.
Tests#
The forge suite is 29 tests. contracts/test/EpochAnchor.t.sol has 25 unit tests covering push chaining, stale heads, empty payloads, independent spaces, expiry, revocation, EOA and ERC-1271 signatures, replay, high s, wrong length, past deadlines, a reverting account, startSeq monotonicity, the chain-id-bound domain separator, and the two nonce-consumption cases. contracts/test/EpochAnchor.fork.t.sol has 4 tests against a Base mainnet fork of https://mainnet.base.org, and it skips itself cleanly when the network is unreachable.
Run them from contracts/:
export FOUNDRY_DISABLE_NIGHTLY_WARNING=1
forge build
forge test -vv # unit suite plus the Base fork suite
forge test -vv --no-match-path 'test/*.fork.t.sol' # offlineLimits#
pushchecks who writes, not what. An authorized session key can append an epoch that no key opens; how kint gets past one, including the owner's--over-skippedsnapshot, is in Epochs on Base. Revoke a leaked key first.- There is no owner rotation. If the owner key is lost, new writes for every space of that owner stop once its session keys expire. Reads never need the owner key.
- The ciphertext lives only in transaction calldata, and reading it back depends on the node keeping a transaction index. kint keeps its own ciphertext cache per epoch as the backup copy. The L1 data availability window is the blob retention window, not forever. Limits and threat model has the rest.
Read Browser core next.
Source: contracts/src/EpochAnchor.sol, contracts/README.md, contracts/abi/EpochAnchor.json, contracts/deployments/base-mainnet.json, contracts/deploy.py, contracts/test/EpochAnchor.t.sol, contracts/test/EpochAnchor.fork.t.sol, src/kint/chain.py, src/kint/cli.py, js/src/chain.ts.