English governs the contract statements on this page. Contract names, addresses and numbers are shown as they are on the chain.
KAY9 Contract Interfaces (binding spec)
This file is the contract between the Solidity implementation, the website, and the services. The website and services build their ABIs from these signatures (viem.parseAbi) until generated ABIs replace them; the Solidity implementation must match them exactly (names, order, types, event indexing). If a change is needed, change this file first.
All contracts: Solidity ^0.8.26, OpenZeppelin v5, pragma abicoder v2 default. Amounts are 18-decimal KAY9 wei unless stated. Trust scores are uint8 0 (worst) … 100 (most trustworthy).
KAY9Token
contract KAY9Token is ERC20, ERC20Permit, ERC20Burnable {
uint256 public constant TOTAL_SUPPLY = 1_000_000_000e18;
constructor(address genesis); // mints TOTAL_SUPPLY to genesis, name "KAY9", symbol "KAY9"
// no other functions beyond standard ERC20 / ERC2612 permit / burn(uint256) / burnFrom(address,uint256)
}
KAY9TeamVesting
contract KAY9TeamVesting {
event Released(address indexed beneficiary, uint256 amount, uint256 totalReleased);
event BeneficiaryTransferred(address indexed previousBeneficiary, address indexed newBeneficiary);
IERC20 public immutable token;
uint256 public constant TOTAL_ALLOCATION = 90_000_000e18;
uint256 public constant TRANCHE_1 = 10_000_000e18; // TGE
uint256 public constant TRANCHE_2 = 40_000_000e18; // +6 calendar months
uint256 public constant TRANCHE_3 = 40_000_000e18; // +12 calendar months
uint64 public immutable tgeTimestamp;
uint64 public immutable unlock6mTimestamp;
uint64 public immutable unlock12mTimestamp;
address public beneficiary;
uint256 public released;
constructor(IERC20 token, address beneficiary, uint64 tge, uint64 unlock6m, uint64 unlock12m);
function unlocked() external view returns (uint256); // cumulative unlocked at block.timestamp
function releasable() external view returns (uint256); // unlocked() - released
function release() external; // permissionless, sends releasable() to beneficiary
function transferBeneficiary(address newBeneficiary) external; // only beneficiary
function schedule() external view returns (uint64[3] memory timestamps, uint256[3] memory amounts);
}
KAY9Genesis (launch vault)
struct LaunchParams {
uint64 startBlock;
uint64 endBlock;
uint64 claimBlock;
uint64 migrationBlock;
uint256 floorPriceQ96;
uint256 auctionTickSpacingQ96;
uint128 requiredCurrencyRaised;
bytes auctionStepsData;
bytes32 salt;
}
contract KAY9Genesis is Ownable2Step {
event Deployed(address indexed token, address indexed teamVesting, address indexed liquidityLock);
event LaunchConfigured(uint256 indexed launchIndex, address indexed auction, LaunchParams params, uint256 impliedFloorFdvWei, uint256 impliedGraduationRaiseWei);
event Launched(uint256 indexed launchIndex, address indexed auction, uint64 startBlock, uint64 endBlock);
event RelaunchScheduled(uint256 earliestRelaunchTimestamp);
event UnsoldSettled(uint256 amountToLiquidity, uint256 amountBurned, uint256 positionTokenId);
event Recovered(uint256 ethAmount, uint256 tokenAmount, uint256 positionTokenId);
// immutables
KAY9Token public immutable token;
KAY9TeamVesting public immutable teamVesting;
KAY9LiquidityLock public immutable liquidityLock;
ILiquidityLauncher public immutable launcher; // 0x0000FffFBE8efE702c8703aE3477FF5dE3d319C0
ILBPStrategy public immutable lbpStrategy; // 0x05d552391067389EE44fec3924157ed33F976000
IPositionManager public immutable positionManager;
IPoolManager public immutable poolManager;
IAllowanceTransfer public immutable permit2;
uint256 public constant LAUNCH_ALLOCATION = 910_000_000e18;
uint256 public constant AUCTION_ALLOCATION = 455_000_000e18;
uint256 public constant LIQUIDITY_RESERVE = 455_000_000e18;
uint24 public constant POOL_FEE = 10_000; // 1 %
int24 public constant POOL_TICK_SPACING = 200;
uint64 public constant MIN_DURATION_BLOCKS = 36_000; // ~1 h at 0.1 s
uint64 public constant MAX_DURATION_BLOCKS = 864_000; // ~24 h
uint256 public constant DUST_THRESHOLD = 1_000e18;
uint256 public constant RELAUNCH_DELAY = 48 hours;
address public auction; // current CCA (ILBPInitializer) or 0
uint256 public launchCount;
uint256 public earliestRelaunchTimestamp;
bool public settled;
constructor(address owner, address teamBeneficiary, uint64 tge, uint64 unlock6m, uint64 unlock12m,
address creatorFeeRecipient, address launcher, address lbpStrategy, address positionManager,
address poolManager, address permit2, address feeSplitter, address beneficiaryVault);
function launch(LaunchParams calldata p) external; // onlyOwner; first launch or after failure + delay
function launchState() external view returns (uint8); // 0 NotLaunched, 1 AuctionLive, 2 AuctionEnded, 3 Migrated, 4 Failed
function poolKey() external view returns (PoolKey memory); // (ETH, KAY9, 10000, 200, poolHook); the hookless recovery pool after recover()
function poolHook() external view returns (address); // canonical InitializerHook, immutable
function recovered() external view returns (bool); // true once recover() rebuilt the pool
function settle() external; // permissionless after migration: unsold+leftover → single-sided LP or burn
function recover() external; // permissionless if graduated but migration failed
function previewLaunch(LaunchParams calldata p) external view returns (address predictedAuction, uint256 impliedFloorFdvWei, uint256 impliedGraduationRaiseWei);
}
KAY9LiquidityLock
contract KAY9LiquidityLock is IERC721Receiver {
event PositionLocked(uint256 indexed tokenId, address indexed beneficiary, address feeSplitter);
IPositionManager public immutable positionManager;
address public immutable feeSplitter; // 0xeFF166AAf189323c58dc27eD1206EB2C37FaACDf
IBeneficiaryVault public immutable beneficiaryVault; // 0xd35E9CA72F64C7F93BE30fad67524323396B36D7
address public immutable creatorFeeRecipient;
uint256[] public lockedTokenIds;
function lockedCount() external view returns (uint256);
function lock(uint256 tokenId) external; // permissionless: register beneficiary then transfer to FeeSplitter
function lockAll() external; // locks every position this contract currently owns (tracked via onERC721Received or pushed ids)
function isLocked(uint256 tokenId) external view returns (bool);
}
KAY9AuditorRegistry
contract KAY9AuditorRegistry is Ownable2Step {
event AuditorAdded(address indexed auditor);
event AuditorRemoved(address indexed auditor);
event ThresholdUpdated(uint8 threshold);
function isAuditor(address) external view returns (bool);
function auditors() external view returns (address[] memory);
function auditorCount() external view returns (uint256);
function threshold() external view returns (uint8);
function addAuditor(address auditor) external; // onlyOwner (Timelock)
function removeAuditor(address auditor) external; // onlyOwner
function setThreshold(uint8 threshold) external; // onlyOwner
}
KAY9Pricing
struct PricingStatus {
bool available; // all checks pass
uint256 twapKay9PerEthE18; // KAY9 per 1 ETH from TWAP (0 if unavailable)
uint256 spotKay9PerEthE18;
uint256 ethUsdE8;
uint64 feedUpdatedAt;
uint32 observationsInWindow;
uint64 oldestObservationAge;
uint64 largestGap;
uint128 poolLiquidity;
uint8 failureCode; // 0 ok, 1 no pool, 2 too few observations, 3 gap too large, 4 low liquidity, 5 feed stale, 6 feed invalid, 7 window not covered
}
contract KAY9Pricing is Ownable2Step {
event Observed(uint64 indexed blockNumber, uint64 timestamp, int24 tick);
event PoolConfigured(bytes32 indexed poolId);
event TargetUpdated(uint8 indexed tier, uint256 usdE8);
event ParamsUpdated(uint32 twapWindow, uint32 minObservations, uint32 maxObservationGap, uint128 minPoolLiquidity, uint16 maxDeviationBps, uint32 maxFeedAge);
uint8 public constant TIER_BASIC = 0; // free, never priced on-chain
uint8 public constant TIER_DEEP = 1;
uint8 public constant TIER_FORENSIC = 2;
function configurePool(PoolKey calldata key) external; // onlyOwner, once (pool must be initialized; must contain KAY9 and native ETH)
function poke() external; // permissionless observation; no-op inside the same block or inside MIN_OBSERVATION_INTERVAL
uint64 public constant MIN_OBSERVATION_INTERVAL = 4; // seconds; CARDINALITY * this exceeds MAX_TWAP_WINDOW
function pricingStatus() external view returns (PricingStatus memory);
function getKay9UsdPriceE8() external view returns (uint256); // reverts PricingUnavailable(code)
function getPriceInKay9(uint8 tier) external view returns (uint256); // KAY9 that is currently worth usdTarget[tier]
function usdTarget(uint8 tier) external view returns (uint256); // E8; 0 = tier inactive
function setUsdTarget(uint8 tier, uint256 usdE8) external; // onlyOwner (Timelock)
function setParams(uint32 twapWindow, uint32 minObservations, uint32 maxObservationGap, uint128 minPoolLiquidity, uint16 maxDeviationBps, uint32 maxFeedAge) external; // onlyOwner
function observationCount() external view returns (uint256);
error PricingUnavailable(uint8 code);
}
usdTarget[tier] is the USD value of the KAY9 an access lock must hold, not a fee. Nothing is ever charged; the oracle exists only to answer "how much KAY9 is worth $100 right now" at the moment a lock opens. Reference targets are $100 for deep and $500 for forensic (100e8 and 500e8). getPriceInKay9 reverts PricingUnavailable(code) rather than returning a manipulable number, which is what stops a cheap lock during an oracle outage.
KAY9AccessVault
Access to the deep and forensic tiers is a lock, never a payment. The vault holds the depositor's KAY9 for one access period and returns all of it afterwards. It has no owner withdrawal path, no reward path, no burn, and no slashing: the only way KAY9 leaves the vault is unlock or renew returning it to the address that deposited it.
struct Access {
uint8 tier; // 0 none, 1 deep, 2 forensic
uint64 startedAt; // period start; identifies the period
uint64 expiresAt; // startedAt + lockDuration
uint32 deepQuota; // frozen at lock time
uint32 forensicQuota; // frozen at lock time
uint32 deepUsed;
uint32 forensicUsed;
uint256 lockedKay9; // principal, the depositor's property
uint256 usdTargetE8; // the USD target the requirement was derived from
uint256 quotedKay9; // the requirement as quoted at lock time, frozen for the period
}
contract KAY9AccessVault is Ownable2Step, ReentrancyGuard {
event AccessLocked(address indexed account, uint8 tier, uint256 lockedKay9, uint256 usdTargetE8, uint64 startedAt, uint64 expiresAt, uint32 deepQuota, uint32 forensicQuota);
event AccessRenewed(address indexed account, uint8 tier, uint256 lockedKay9, uint256 toppedUp, uint256 returned, uint64 startedAt, uint64 expiresAt);
event AccessUpgraded(address indexed account, uint256 lockedKay9, uint256 toppedUp, uint32 forensicQuota);
event AccessUnlocked(address indexed account, uint256 returnedKay9);
event QuotaConsumed(address indexed account, uint8 tier, uint32 deepUsed, uint32 forensicUsed);
event QuotaRestored(address indexed account, uint8 tier, uint32 deepUsed, uint32 forensicUsed);
event QuotaConfigured(uint8 indexed tier, uint32 deepQuota, uint32 forensicQuota);
event LockDurationUpdated(uint64 seconds_);
event AuditHubUpdated(address auditHub);
uint8 public constant TIER_DEEP = 1;
uint8 public constant TIER_FORENSIC = 2;
uint64 public constant MIN_LOCK_DURATION = 7 days;
uint64 public constant MAX_LOCK_DURATION = 365 days;
uint32 public constant MAX_QUOTA = 1000;
IERC20 public immutable kay9;
KAY9Pricing public immutable pricing;
address public auditHub;
uint64 public lockDuration; // default 30 days
function deepQuotaOf(uint8 tier) external view returns (uint32); // deep: 4, forensic: 4
function forensicQuotaOf(uint8 tier) external view returns (uint32); // deep: 0, forensic: 1
uint256 public totalLocked; // sum of every principal held
function quoteLock(uint8 tier) external view returns (uint256 kay9Amount, uint256 usdTargetE8); // reverts PricingUnavailable
function lock(uint8 tier, uint256 maxKay9) external; // requires no live period
function lockWithPermit(uint8 tier, uint256 maxKay9, uint256 deadline, uint8 v, bytes32 r, bytes32 s) external;
function renew(uint8 tier, uint256 maxKay9) external; // only at or after expiresAt; requotes, tops up or returns the difference, resets quota
function upgrade(uint256 maxKay9) external; // deep to forensic inside a live period; deepUsed is preserved
function unlock() external; // only at or after expiresAt; returns the whole principal
function accessOf(address account) external view returns (Access memory);
function isActive(address account) external view returns (bool);
function deepRemaining(address account) external view returns (uint32);
function forensicRemaining(address account) external view returns (uint32);
function canRequest(address account, uint8 tier) external view returns (bool);
function consume(address account, uint8 tier) external returns (uint64 periodStartedAt); // only auditHub
function restore(address account, uint8 tier, uint64 periodStartedAt) external; // only auditHub; no-op unless the period still matches
function setAuditHub(address auditHub_) external; // onlyOwner (Timelock)
function setQuota(uint8 tier, uint32 deepQuota, uint32 forensicQuota) external; // onlyOwner (Timelock)
function setLockDuration(uint64 seconds_) external; // onlyOwner (Timelock), within [MIN, MAX]
error ZeroAddress();
error InvalidTier(uint8 tier);
error AccessLive(uint64 expiresAt);
error AccessRecordExists(uint64 expiresAt);
error NoAccess();
error NotExpired(uint64 expiresAt);
error NotTheAuditHub(address caller);
error RequirementAboveMax(uint256 required, uint256 maxKay9);
error QuotaExhausted(uint8 tier);
error TierNotPermitted(uint8 have, uint8 want);
error NotAnUpgrade(uint8 tier);
error InvalidLockDuration();
error InvalidQuota();
}
Rules that the tests pin and that the rest of the system may rely on:
- The requirement is frozen for the period.
lockreads the oracle once, storesquotedKay9and never reads it again. A later KAY9 price move never asks the depositor for more, and never shortens or voids a live period.renewrequotes; that is the only place the number changes. lockis only for an account with no record at all. An account holding an ended period callsreneworunlock;lockrevertsAccessRecordExistsrather than misreporting the period as unexpired.renewis refused beforeexpiresAt. Otherwise a depositor could spend a quota, renew for nothing, and spend it again. Renewal at or after expiry needs no unlock-and-relock round trip: the vault tops up or returns the difference against the fresh quote.upgradepreservesdeepUsed. Deep quota is identical in both tiers, so upgrading buys the forensic slot and nothing else.- Every path out returns principal to the depositor.
unlockreturnslockedKay9in full. There is no function, owner-only or otherwise, that sends a depositor's KAY9 anywhere else. unlocknever touches the oracle. An oracle outage cannot trap a depositor's tokens.- A stale or untrusted oracle blocks new locks.
lock,renewandupgradebubblePricingUnavailable, so nobody gets a suspiciously cheap period during an outage. - Quota is consumed on the chain, by the hub, not by any website.
consumeis callable only by the configured audit hub and returns the period it debited, so a laterrestorecannot credit a different period.
KAY9ScanRegistry
The permanent record of automatic basic scans. Separate from KAY9Registry on purpose: the two carry different claims, and the product depends on nobody confusing them.
KAY9ScanRegistry | KAY9Registry | |
|---|---|---|
| Claim | reproducibility — "this is what the published engine computes" | consensus — "two of three auditors signed the same result" |
| Writer | one authorised scanner, or any auditor | the audit hub only, after quorum |
| Trigger | automatic, on discovery and on re-scan | a request that consumed access quota |
| Cost shape | one transaction per batch | one transaction per report |
| Anyone can recompute it | yes, from public chain state at the stated block | no, it depends on the auditors' private analysis |
Batching is in the design from the first day rather than added later. A basic scan is unsolicited, so the number of them is set by how many tokens launch, not by how many people pay. One transaction per scan makes the cost of watching a chain scale with that chain's activity, which is the wrong shape; one transaction per batch makes it scale with time. A batch of one is a legal batch, so a single urgent scan is not a special case anywhere in the code.
struct ScanBatch {
bytes32 root; // Merkle root over the batch's scan leaves
uint32 count; // scans in the batch
uint32 engineVersion;
uint64 committedAt; // block.timestamp
uint64 committedBlock; // block.number — Ethereum's, on this Orbit chain
address scanner;
string uri; // content-addressed batch document
}
struct ScanSummary {
bytes32 chainKey;
bytes32 assetId;
uint8 overallTrust;
uint8 confidence; // separate from the score, never folded into it
uint64 flags;
uint64 scannedAtBlock;
}
contract KAY9ScanRegistry {
event ScanBatchCommitted(uint256 indexed batchId, bytes32 indexed root, uint32 count, uint32 engineVersion, string uri, address indexed scanner);
event AssetScanned(bytes32 indexed chainKey, bytes32 indexed assetId, uint256 indexed batchId, uint8 overallTrust, uint8 confidence, uint64 flags, uint64 scannedAtBlock);
event ScannerUpdated(address indexed scanner, bool allowed);
uint32 public constant MAX_BATCH = 500;
KAY9AuditorRegistry public immutable auditors;
mapping(address => bool) public isScanner;
function assetKey(bytes32 chainKey, bytes32 assetId) external pure returns (bytes32); // keccak256(abi.encode(chainKey, assetId))
function scanLeaf(bytes32 chainKey, bytes32 assetId, uint8 overallTrust, uint8 confidence, uint64 flags, uint32 engineVersion, uint64 scannedAtBlock, bytes32 reportHash) external pure returns (bytes32);
function commitScanBatch(bytes32 root, uint32 count, uint32 engineVersion, string calldata uri, ScanSummary[] calldata summaries) external returns (uint256 batchId); // scanner or auditor
function verifyScan(uint256 batchId, bytes32 leaf, bytes32[] calldata proof) external view returns (bool);
function batchCount() external view returns (uint256);
function getBatch(uint256 batchId) external view returns (ScanBatch memory);
function latestScan(bytes32 chainKey, bytes32 assetId) external view returns (bool scanned, uint256 batchId, uint8 overallTrust);
function setScanner(address scanner, bool allowed) external; // onlyOwner
}
The leaf definition is part of the protocol, not an implementation detail. A third party verifying a scan hashes exactly those fields in exactly that order:
leaf = keccak256(keccak256(abi.encode(
chainKey, assetId, overallTrust, confidence, flags, engineVersion, scannedAtBlock, reportHash
)))
Double-hashed so a leaf can never be confused with an internal node, which is the standard defence against a second-preimage attack on a Merkle tree. Internal nodes hash sorted pairs, so a proof carries no left-or-right flags and cannot be replayed against a differently shaped tree.
Notes that a caller has to know:
latestScanreturnsscannedbecause an unsetuint8defaults to 0, the worst possible score. Without it, "never looked at" and "looked at and found the worst possible reading" are the same answer.verifyScanreverts on an unknown batch rather than returning false. False would be indistinguishable from "not in this batch", and a caller that got the id wrong deserves to be told.- The contract does not check that the summaries are the batch's leaves, and cannot. Checking would mean rebuilding the tree on-chain, which costs more than the record is worth and defeats the point of batching. Instead it publishes both the root and the summaries, so a scanner that publishes summaries its own root does not support is caught by the first person who checks — and the evidence stays on-chain for good.
countis the batch;summariesis the subset indexed on-chain. Every scan in the batch is committed torootand provable withverifyScan, whether or not it is indexed. Indexing an asset costs a cold storage write — measured at about 24,700 gas, against roughly 150,000 for the whole batch however large — so on a chain producing tens of thousands of launches a day, indexing everything would cost thousands of dollars a month and indexing what people have actually bought costs tens. A batch that claims to hold fewer scans than it indexes is refused withCountTooSmall.- A scanner can only ever append. There is no function, owner-only or otherwise, that alters or removes a committed batch. Removing a scanner stops it committing again and changes nothing it already committed.
- Auditors may commit without a separate scanner registration. They already run the engine for deep and forensic work, so a second registration would be bookkeeping with no security value.
KAY9Registry
struct AuditResult {
bytes32 chainKey;
bytes32 assetId;
uint8 overallTrust;
uint8 contractTrust;
uint8 liquidityTrust;
uint8 holderTrust;
uint8 insiderTrust;
uint8 creatorTrust;
uint8 tradingTrust;
uint8 botTrust;
uint64 flags;
uint32 engineVersion;
uint64 analyzedAt;
bytes32 reportHash;
string reportURI;
}
struct ReportMeta {
uint256 jobId; // 0 for unsolicited watchdog reports
address requester; // address(0) for watchdog reports
uint8 declaredRequesterKind; // 0 unknown, 1 independent, 2 token creator, 3 integration
uint8 tier; // access tier the request consumed; 0 for watchdog reports
}
struct ReportRecord {
uint256 jobId; // 0 for unsolicited watchdog reports
address requester;
uint8 declaredRequesterKind;
uint8 tier;
AuditResult result;
address[] signers;
uint64 committedAt; // block.timestamp
uint64 committedBlock;
}
contract KAY9Registry {
event ReportRecorded(uint256 indexed reportId, bytes32 indexed chainKey, bytes32 indexed assetId, uint256 jobId, uint8 overallTrust, bytes32 reportHash);
address public immutable auditHub;
bytes32 public constant CHAIN_ROBINHOOD = keccak256("eip155:4663");
bytes32 public constant CHAIN_ROBINHOOD_TESTNET = keccak256("eip155:46630");
bytes32 public constant CHAIN_BNB = keccak256("eip155:56");
bytes32 public constant CHAIN_SOLANA = keccak256("solana:mainnet");
function assetKey(bytes32 chainKey, bytes32 assetId) external pure returns (bytes32); // keccak256(abi.encode(chainKey, assetId))
function evmAssetId(address token) external pure returns (bytes32); // bytes32(uint256(uint160(token)))
function recordReport(ReportMeta calldata meta, AuditResult calldata result, address[] calldata signers) external returns (uint256 reportId); // only auditHub
function reportCount() external view returns (uint256);
function getReport(uint256 reportId) external view returns (ReportRecord memory);
function getReports(uint256 offset, uint256 limit) external view returns (ReportRecord[] memory); // newest-first paging handled by caller; returns [offset, offset+limit) clamped to the end of the log
function historyCount(bytes32 chainKey, bytes32 assetId) external view returns (uint256);
function history(bytes32 chainKey, bytes32 assetId, uint256 offset, uint256 limit) external view returns (uint256[] memory reportIds); // same clamping as getReports
function latest(bytes32 chainKey, bytes32 assetId) external view returns (bool exists, ReportRecord memory record);
function latestSummary(bytes32 chainKey, bytes32 assetId) external view returns (bool exists, uint256 reportId, uint8 overallTrust, uint64 flags, uint32 engineVersion, uint64 committedAt);
function latestSummaryForToken(bytes32 chainKey, address token) external view returns (bool exists, uint256 reportId, uint8 overallTrust, uint64 flags, uint32 engineVersion, uint64 committedAt);
function scoreHistory(bytes32 chainKey, bytes32 assetId, uint256 offset, uint256 limit) external view returns (uint64[] memory committedAt, uint8[] memory overallTrust);
}
latestSummary is the read a wallet, DEX, launchpad or badge makes: one call, no arrays of structs, no report body. scoreHistory is the read a risk-over-time chart makes. Both exist so that consuming KAY9 risk data never requires an off-chain API or a KAY9-operated frontend.
A report is never overwritten. A new audit of the same asset appends a new record, and history keeps every one of them in commitment order. latest therefore means "most recent snapshot", not "current truth", and every surface that renders it also renders committedAt.
Both paging functions clamp rather than revert. offset at or past the end returns an empty array, and limit is saturating: type(uint256).max means "to the end of the log" and never overflows. Callers may therefore treat a maximal limit as "everything from here" without first reading reportCount.
KAY9AuditHub
The hub takes requests, enforces access on-chain, collects auditor attestations, and appends finalized results to the registry. No KAY9 changes hands here. There is no escrow, no fee, no payment split and no burn; the only thing a request spends is a quota unit in the access vault.
enum JobStatus { None, Requested, Fulfilled, Disputed, Expired }
struct Job {
address requester;
bytes32 chainKey;
bytes32 assetId;
uint8 tier; // 1 deep, 2 forensic
uint8 declaredRequesterKind; // 0 unknown, 1 independent, 2 token creator, 3 integration
uint64 requestedAt; // the analysis pin: a Unix timestamp, resolved per-chain
uint64 requestedBlock; // block.number at request time; Ethereum height on this Orbit
// chain, kept for the audit trail only — never an RPC pin
uint64 accessPeriodStartedAt; // the vault period the quota unit came from
uint64 slaSeconds; // the service level in force at request time, frozen for the job's life
uint8 attestations; // how many auditors have taken a position
JobStatus status;
uint256 reportId; // set when fulfilled
}
contract KAY9AuditHub is Ownable2Step, EIP712, ReentrancyGuard {
event AuditRequested(uint256 indexed jobId, address indexed requester, bytes32 indexed chainKey, bytes32 assetId, uint8 tier, uint8 declaredRequesterKind, uint64 expiresAt);
event AuditAttested(uint256 indexed jobId, address indexed auditor, bytes32 digest, uint8 votesForDigest);
event AuditFulfilled(uint256 indexed jobId, uint256 indexed reportId, uint8 overallTrust, address[] signers);
event AuditDisputed(uint256 indexed jobId, uint8 attestations, uint8 bestAgreement, uint8 required);
event AuditExpired(uint256 indexed jobId, address indexed requester);
event WatchdogReportPublished(uint256 indexed reportId, bytes32 indexed chainKey, bytes32 indexed assetId, address[] signers);
event SlaUpdated(uint64 slaSeconds);
event RequestsPaused(bool paused);
uint8 public constant TIER_DEEP = 1; // must equal KAY9AccessVault.TIER_DEEP
uint8 public constant TIER_FORENSIC = 2; // must equal KAY9AccessVault.TIER_FORENSIC
uint8 public constant REQUESTER_UNKNOWN = 0;
uint8 public constant REQUESTER_INDEPENDENT = 1;
uint8 public constant REQUESTER_CREATOR = 2;
uint8 public constant REQUESTER_INTEGRATION = 3;
KAY9Registry public immutable registry;
KAY9AuditorRegistry public immutable auditors;
KAY9AccessVault public accessVault; // zero before the token; set once, by governance
// reportURI is deliberately not in this type: it says only where a copy of the report body
// currently lives, not what the report says, so it never affects whether two auditors agree.
bytes32 public constant RESULT_TYPEHASH = keccak256("AuditResult(uint256 jobId,bytes32 chainKey,bytes32 assetId,uint8 overallTrust,uint8 contractTrust,uint8 liquidityTrust,uint8 holderTrust,uint8 insiderTrust,uint8 creatorTrust,uint8 tradingTrust,uint8 botTrust,uint64 flags,uint32 engineVersion,uint64 analyzedAt,bytes32 reportHash)");
uint64 public constant MIN_SLA = 1 hours;
uint64 public constant MAX_SLA = 30 days;
uint64 public slaSeconds; // default 6 hours
bool public requestsPaused;
uint256 public jobCount;
function requestAudit(bytes32 chainKey, bytes32 assetId, uint8 tier, uint8 declaredRequesterKind) external returns (uint256 jobId);
function attest(uint256 jobId, AuditResult calldata result, bytes[] calldata signatures) external returns (uint256 reportId); // reportId is 0 until quorum lands
function markExpired(uint256 jobId) external; // permissionless once the SLA has elapsed; restores the quota unit
function publishWatchdogReport(AuditResult calldata result, bytes[] calldata signatures) external returns (uint256 reportId);
function watchdogReportCommitted(bytes32 digest) external view returns (bool);
function getJob(uint256 jobId) external view returns (Job memory);
function attestationOf(uint256 jobId, address auditor) external view returns (bytes32 digest);
function digestVotes(uint256 jobId, bytes32 digest) external view returns (uint8);
function bestAgreement(uint256 jobId) external view returns (uint8);
function jobExpiresAt(uint256 jobId) external view returns (uint64);
function hashResult(uint256 jobId, AuditResult calldata result) external view returns (bytes32);
function setSla(uint64 slaSeconds_) external; // onlyOwner (Timelock)
function setAccessVault(KAY9AccessVault accessVault_) external; // onlyOwner (Timelock), once only
function setRequestsPaused(bool paused) external; // onlyOwner (Timelock); results, disputes and expiries are never pausable
// EIP-712 domain: name "KAY9AuditHub", version "1"
error ZeroAddress();
error RequestsArePaused();
error UnknownJob(uint256 jobId);
error WrongJobStatus(uint256 jobId, JobStatus status);
error ResultAssetMismatch();
error InvalidTier(uint8 tier);
error TierMismatch();
error InvalidRequesterKind(uint8 kind);
error NoSignatures();
error NotAnAuditor(address signer);
error AlreadyAttested(uint256 jobId, address auditor);
error SignersNotSorted(address previous, address current);
error QuorumNotMet(uint256 provided, uint256 required);
error NotExpired(uint256 jobId, uint64 expiresAt);
error InvalidSla();
error DuplicateWatchdogReport(bytes32 digest);
}
How a request is authorised
The vault arrives after the hub. The watchdog goes live before $KAY9 exists, and KAY9Registry binds to its hub immutably, so the hub must be the final one from its first deployment — but the vault holds KAY9 and cannot exist yet. So the hub deploys with accessVault == address(0): requestAudit reverts AccessVaultNotSet, and publishWatchdogReport — permissionless, no quota, no requester — works from day one, which is how deep and forensic reports are published in beta. Governance calls setAccessVault exactly once at launch; a second call reverts AccessVaultAlreadySet, so the binding ends up as permanent as an immutable would have been.
requestAudit calls accessVault.consume(msg.sender, tier), which reverts unless the caller holds a live period of at least the requested tier with quota left. The website is never consulted and cannot grant access; any wallet, script or contract calling the hub directly gets exactly the same answer. declaredRequesterKind is metadata the caller states about itself and the hub records it verbatim, which is why every surface labels it as declared.
Attestation and quorum
Each auditor takes exactly one position per job. attest accepts one or more 65-byte ECDSA signatures over the EIP-712 digest of (jobId, result); every recovered signer must be an active auditor and must not have attested this job already. A relay holding two agreeing signatures sends them in one transaction, which is the ordinary path; a disagreeing auditor sends its own signature in its own transaction.
- The moment one digest reaches
auditors.threshold()votes from auditors who are still in the active set, the job finalises against that result and the record is appended to the registry. Attestations are recorded when they arrive, but the auditor set can change between the first and the last of them, so the holders of a position are re-checked at the moment it would finalise. An operator removed through the timelock cannot carry a job over the line on a stale vote. - The job becomes
Disputedas soon as agreement is arithmetically out of reach, that is whenbestAgreement + silent < threshold, wheresilentcounts currently active auditors who have not attested to this job at all, notauditorCount - attestations. The raw difference can undercount silence once the set has rotated mid-job — an attestation from an auditor since removed still incrementsattestations— and read a still-active, still-silent auditor as having already spoken, when it could still bring either open position to quorum. With three auditors, a threshold of two and no rotation, three mutually different results still dispute the job. - A
DisputedorExpiredjob restores its quota unit to the period it came from, so a caller is never charged a quota for an audit that produced no result.
Contradictory results are never averaged, and a dispute is a public on-chain state with the conflicting digests readable per auditor. Nothing hides disagreement.
Replay and misuse resistance
The EIP-712 domain binds the chain id and the hub address, so a signature cannot move to another chain or another deployment. The signed payload contains the job id, so it cannot move to another job, and result.chainKey/result.assetId must equal the job's, so it cannot describe another asset. attestationOf blocks the same auditor signing twice. For watchdog reports, which have no job to consume, signers must be strictly ascending by address and each digest may be committed once.
Requester neutrality
Nothing in the request path reaches the scoring path. The hub records who asked and what they declared themselves to be, and passes only chainKey, assetId and tier to the auditors through the AuditRequested event. There is no field an auditor could read that says the creator paid, because nobody pays. When the auditors establish on-chain that the requester is the asset's deployer, they set flag bit 18; a self-declaration alone is rendered as declared and unverified.
Flags bitmask (uint64)
| Bit | Name | Meaning |
|---|---|---|
| 0 | MINTABLE | supply can be increased |
| 1 | FREEZABLE | balances can be frozen / paused |
| 2 | BLACKLIST | address deny-list present |
| 3 | MUTABLE_TAX | transfer fee can be changed |
| 4 | PROXY | upgradeable proxy |
| 5 | OWNER_PRIVILEGES | owner has non-standard powers |
| 6 | LOW_LIQUIDITY | liquidity below thresholds |
| 7 | UNLOCKED_LIQUIDITY | LP not locked / withdrawable |
| 8 | HOLDER_CONCENTRATION | top holders exceed thresholds |
| 9 | LINKED_WALLETS | clustered wallets share funding |
| 10 | CREATOR_HISTORY | creator linked to failed/rugged launches |
| 11 | SNIPERS | early-block buyers dominate |
| 12 | BUNDLED_BUYS | bundled insider buys detected |
| 13 | WASH_TRADING | wash-like volume pattern |
| 14 | HONEYPOT_SIGNALS | sell restrictions suspected |
| 15 | HIDDEN_TRANSFER_RESTRICTION | non-standard transfer logic |
| 16 | UNVERIFIED_SOURCE | source not verified on explorer (informational; scores zero) |
| 17 | INSUFFICIENT_DATA | analysis partial |
| 18 | REQUESTER_IS_DEPLOYER | the requesting address was verified on-chain as the asset's deployer (informational; scores zero) |
| 19 | MONITORING_UPDATE | this report supersedes an earlier one for the same asset (informational; scores zero) |
Cross-chain identity
chainKey = keccak256(bytes(caip2)), e.g. "eip155:4663", "eip155:56", "solana:mainnet". assetId: EVM bytes32(uint256(uint160(addr))); Solana = the 32-byte mint public key.
Implementation supersets (added during implementation, ABI-compatible)
The Solidity implementation exposes these additional members. They are supersets of the spec above; nothing above was removed or changed.
// KAY9Genesis
function markFailed() public; // permissionless: records a failed launch (auction ended without graduation, or migration recovered) and starts the 48 h relaunch cooldown
function previewLaunch(LaunchParams calldata p) external view returns (address predictedAuction, uint256 impliedFloorFdvWei, uint256 impliedGraduationRaiseWei);
// KAY9Pricing
event FeedUpdated(address feed);
function setFeed(AggregatorV3Interface feed) external; // onlyOwner (Timelock)
function observationAt(uint256 age) external view returns (Observation memory); // Observation {uint64 blockNumber; uint64 timestamp; int24 tick}
// KAY9LiquidityLock
function track(uint256 tokenId) external; // permissionless: registers a position this contract already owns (PositionManager mints without a receiver callback)
Settlement range note: with native ETH as currency0 and KAY9 as currency1, a KAY9-only position must sit below the current tick ([minUsableTick, currentTick - tickSpacing]); in price terms that is KAY9 offered at prices above the current market price, which is what ARCHITECTURE §4.2 describes.