Skip to content

docs/CONTRACT_INTERFACES.md

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. lock reads the oracle once, stores quotedKay9 and never reads it again. A later KAY9 price move never asks the depositor for more, and never shortens or voids a live period. renew requotes; that is the only place the number changes.
  • lock is only for an account with no record at all. An account holding an ended period calls renew or unlock; lock reverts AccessRecordExists rather than misreporting the period as unexpired.
  • renew is refused before expiresAt. 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.
  • upgrade preserves deepUsed. Deep quota is identical in both tiers, so upgrading buys the forensic slot and nothing else.
  • Every path out returns principal to the depositor. unlock returns lockedKay9 in full. There is no function, owner-only or otherwise, that sends a depositor's KAY9 anywhere else.
  • unlock never touches the oracle. An oracle outage cannot trap a depositor's tokens.
  • A stale or untrusted oracle blocks new locks. lock, renew and upgrade bubble PricingUnavailable, so nobody gets a suspiciously cheap period during an outage.
  • Quota is consumed on the chain, by the hub, not by any website. consume is callable only by the configured audit hub and returns the period it debited, so a later restore cannot 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.

KAY9ScanRegistryKAY9Registry
Claimreproducibility — "this is what the published engine computes"consensus — "two of three auditors signed the same result"
Writerone authorised scanner, or any auditorthe audit hub only, after quorum
Triggerautomatic, on discovery and on re-scana request that consumed access quota
Cost shapeone transaction per batchone transaction per report
Anyone can recompute ityes, from public chain state at the stated blockno, 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:

  • latestScan returns scanned because an unset uint8 defaults to 0, the worst possible score. Without it, "never looked at" and "looked at and found the worst possible reading" are the same answer.
  • verifyScan reverts 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.
  • count is the batch; summaries is the subset indexed on-chain. Every scan in the batch is committed to root and provable with verifyScan, 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 with CountTooSmall.
  • 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 Disputed as soon as agreement is arithmetically out of reach, that is when bestAgreement + silent < threshold, where silent counts currently active auditors who have not attested to this job at all, not auditorCount - attestations. The raw difference can undercount silence once the set has rotated mid-job — an attestation from an auditor since removed still increments attestations — 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 Disputed or Expired job 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)

BitNameMeaning
0MINTABLEsupply can be increased
1FREEZABLEbalances can be frozen / paused
2BLACKLISTaddress deny-list present
3MUTABLE_TAXtransfer fee can be changed
4PROXYupgradeable proxy
5OWNER_PRIVILEGESowner has non-standard powers
6LOW_LIQUIDITYliquidity below thresholds
7UNLOCKED_LIQUIDITYLP not locked / withdrawable
8HOLDER_CONCENTRATIONtop holders exceed thresholds
9LINKED_WALLETSclustered wallets share funding
10CREATOR_HISTORYcreator linked to failed/rugged launches
11SNIPERSearly-block buyers dominate
12BUNDLED_BUYSbundled insider buys detected
13WASH_TRADINGwash-like volume pattern
14HONEYPOT_SIGNALSsell restrictions suspected
15HIDDEN_TRANSFER_RESTRICTIONnon-standard transfer logic
16UNVERIFIED_SOURCEsource not verified on explorer (informational; scores zero)
17INSUFFICIENT_DATAanalysis partial
18REQUESTER_IS_DEPLOYERthe requesting address was verified on-chain as the asset's deployer (informational; scores zero)
19MONITORING_UPDATEthis 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.