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#

fieldvalue
address0xa22E03f7a4145Bf4909a83595C90a38E14d79600
chainBase mainnet, chain id 8453
deploy tx0xf2e03cd1c4e7ca005ee602a6c61ed50861d2238acee217b2c19f8670ba2cc9f6
deploy block51081696 (Sep 9 2026)
deployer0xC635e6Eb223aE14143E23cEEa9440bC773dc87Ec
deploy receiptgasUsed 885,843; effective gas price 7,833,683 wei; L1 fee 1,526,707,829 wei
compilersolc 0.8.28, evm cancun, optimizer 200 runs, bytecode_hash none
sourceverified 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:

mappingkeyvalue
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
authNonceaddress owneruint256 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#

functionwho may callwhat 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 aheadAppends 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 addressSets sessionKeyExpiry[msg.sender][key] = expiry (0 revokes). Increments authNonce[msg.sender]. Emits SessionKeySet.
revokeSessionKey(address key)anyone, for their own addressSets 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 itselfChecks 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 addressRecords 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)anyoneTrue when writer == owner, or when sessionKeyExpiry[owner][writer] > block.timestamp.
DOMAIN_SEPARATOR() view returns (bytes32)anyoneThe 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)anyoneThe exact digest an owner signs to authorize a session key, so a client never rebuilds the domain by hand.
head, sessionKeyExpiry, startSeq, authNonceanyoneThe 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#

eventindexeddata
Epochaddress owner, bytes32 space, address writeruint64 seq, bytes32 prev, bytes32 digest, uint64 prevBlock
SessionKeySetaddress owner, address keyuint64 expiry
StartSeqSetaddress owner, bytes32 spaceuint64 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#

errorraised bywhen
NotAuthorized()pushcanWrite(owner, msg.sender) is false: a stranger, or a session key that is revoked, expired or never set.
EmptyCiphertext()pushct is zero bytes long.
StaleHead(bytes32 expected, bytes32 got)pushprev is not the current head digest. expected is the digest on chain, got is the prev you sent.
AuthorizationExpired()setSessionKeyBySigblock.timestamp > deadline. Checked before anything else.
BadSignature()setSessionKeyBySigThe signature does not verify for the current nonce. The signature setSessionKeyBySig checks lists every case.
SeqNotMonotone()setStartSeqseq 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.

signatureselector 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 payload prints the EIP-712 typed data for the owner to sign, with the current authNonce(owner) and a deadline 300 seconds out. kint authorize submit --deadline <d> --expiry <e> --signature - then sends setSessionKeyBySig from the session key, which pays the gas. --signature - reads the signature from stdin; a path is read and then deleted.
  • kint authorize page serves a one-shot loopback page for a Base Account owner, and the account sends setSessionKey(key, expiry) itself. kint then polls canWrite(owner, key) until the key shows up.
  • kint authorize direct sends setSessionKey from an owner key read from KINT_OWNER_KEY (env only, never argv), and refuses when that key is not the owner.
  • kint authorize burn-nonce re-sends setSessionKey with the key's current expiry, from the account in KINT_OWNER_KEY, which only consumes the nonce. Like direct, 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-1271 isValidSignature(digest, signature) on the owner and requires the magic value 0x1626ba7e. Any other bytes4, or a revert inside the account, is BadSignature. A reply that does not decode as a bytes4 (too short, say) reverts without an error name instead, because Solidity's try/catch does 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. A v of 0 or 1 is normalised to 27 or 28, and any other v is rejected. An s above half the secp256k1 order is rejected, so s has one encoding. ecrecover must return the owner and never the zero address. Every failure is BadSignature.

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 send setSessionKey(key, expiry) itself: that one transaction deploys the account and authorizes the key. After that the account has code, and setSessionKeyBySig works for every later key. kint authorize page does 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.org

For 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.org
0x7df5378a8c4fa8664365cf8956033cac2f4e3cbf7a4767a377d759b262f01a13
2
51081880

The 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.org

Gas#

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:

bucketcalldata bytesgasUsed
4 KB4,356191,040
8 KB8,452355,390
16 KB16,644681,840
32 KB33,0281,336,090
64 KB65,7962,642,640
96 KB98,5643,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.py

It 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' # offline

Limits#

  • push checks 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-skipped snapshot, 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.

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.