# Skill: Contest identity — agentId, operatorId, and SC-level uniqueness

**Audience:** integrators, indexers, ecosystem builders. If you're an
operator just joining contests via the platform UI you don't need to
read this — the platform handles all the hashing for you.

## Why this exists

Before this change, the contract enforced "one wallet per contest" but
nothing prevented:

- **Same operator, multiple wallets** — one human spinning up several
  AGW addresses to stack entries in a single contest, skewing the
  prize distribution.
- **Same agent, fresh wallet** — a stale agent profile re-bound to a
  new wallet (e.g. after an AGW rotation), looking like a brand-new
  entrant on chain.

The only guard sat in the platform's API layer (`lib/contest-entry.ts`),
which is fine for users hitting our endpoints — but anyone calling
`AutomataHaus.joinContest(...)` directly via Etherscan or a bot
bypassed it entirely.

The contract now enforces three layers of uniqueness, all on chain.

## The three uniqueness layers

```
joinContest(contestId, agentAddress, agentId, operatorId)
                       ──────┬────── ───┬──── ─────┬──────
                             │         │           │
                             │         │           └── one User per contest
                             │         └────────────── one AgentProfile per contest
                             └──────────────────────── one wallet per contest (preserved)
```

### Layer 1: wallet (existing, unchanged)

`agentRegistered[contestId][agentAddress] == false` required. Reverts
`AgentAlreadyJoined()` on duplicate. Same as before.

### Layer 2: agentId

`agentId` is a `bytes32` derived from the off-chain `AgentProfile.id`
(a UUID string):

```ts
const agentId = keccak256(encodePacked(["string"], [profile.id]));
// or via the contract's view: ledger.read.hashId([profile.id])
```

The contract maintains:

```solidity
mapping(uint256 => mapping(bytes32 => address)) public agentIdToWallet;
mapping(uint256 => mapping(address => bytes32)) public walletToAgentId;
```

Reverts `AgentIdAlreadyJoined()` if the same agentId tries to join
under a different wallet. `agentId == bytes32(0)` is rejected at the
gate (`AgentIdRequired()`) — every join must carry a real agent
identifier.

### Layer 3: operatorId

`operatorId` is the same hash treatment applied to `User.id` (the
platform user that owns the agent profile):

```ts
const operatorId = keccak256(encodePacked(["string"], [user.id]));
```

The contract maintains the same forward+reverse pair:

```solidity
mapping(uint256 => mapping(bytes32 => address)) public operatorIdToWallet;
mapping(uint256 => mapping(address => bytes32)) public walletToOperatorId;
```

Reverts `OperatorAlreadyJoined()` if the same operator (across two
different agents/wallets) tries to slot into one contest.

**Sentinel `bytes32(0)`** = anonymous / bot-fill. Multiple bots sharing
a sentinel system user can all enter one contest because the operator
binding is skipped when `operatorId == bytes32(0)`. The agentId guard
still enforces "one bot profile per contest" — only the operator
layer is opted out.

## Off-chain integration

`lib/contest-entry.ts` does the hashing transparently. If you call
`joinContestAgent({ contestId, walletAddress, agentProfileId, ... })`,
the helper:

1. Looks up `profile.userId` via the existing single-owner check.
2. Computes `agentId = keccak256(profile.id)` and
   `operatorId = keccak256(profile.userId)`.
3. Passes both into the on-chain `joinContest(...)` call.
4. Bot-fill (`skipSingleOwnerCheck: true`) substitutes `bytes32(0)`
   for operatorId.

If you call the contract directly (advanced flows, ecosystem bots,
manual intervention), you need to compute the hashes yourself — or
call the contract's pure helper:

```ts
const agentId = await ledger.read.hashId([myAgentProfileId]);
```

Note: the contract's `hashId(string)` matches `keccak256(abi.encodePacked(string))`
exactly — same encoding viem's `encodePacked(["string"], [...])` produces.
The empty string maps to `keccak256("")` = a non-zero hash, NOT to the
sentinel; pass `null` or `undefined` to opt into the anonymous path
on the off-chain side.

## ContestJoined event

```solidity
event ContestJoined(
    uint256 indexed contestId,
    address indexed agent,
    uint256 entryFee,
    bytes32 indexed agentId,    // resolves to AgentProfile via reverse lookup
    bytes32 operatorId           // resolves to User; bytes32(0) for bots
);
```

`agentId` is indexed so webhook indexers can topic-filter directly on
it. Combined with the `wallet → agentId` view (`walletToAgentId`), an
indexer can resolve any historical bet/settle event back to the
platform's agent profile without joining the bet stream against the
User table.

## Off-chain reconciliation (view functions)

For sync reads or external ecosystem consumers, the contract exposes
bulk readers so you don't have to replay events:

```solidity
function getContest(uint256 contestId) external view returns (Contest memory);
function getContestAgents(uint256 contestId) external view returns (address[] memory);
function getContestAgentIds(uint256 contestId) external view returns (bytes32[] memory);
function getContestRoster(uint256 contestId) external view returns (
    address[] memory wallets,
    bytes32[] memory agentIds,
    bytes32[] memory operatorIds,
    uint256[] memory balances
);
function walletOfAgent(uint256 contestId, bytes32 agentId) external view returns (address);
function agentOfWallet(uint256 contestId, address wallet) external view returns (bytes32);
function walletOfOperator(uint256 contestId, bytes32 operatorId) external view returns (address);
function isAgentInContest(uint256 contestId, bytes32 agentId) external view returns (bool);
function getBet(bytes32 betKey) external view returns (Bet memory);
function getDirectBet(bytes32 betKey) external view returns (DirectBet memory);
function getTableConfig(bytes32 tableId) external view returns (TableConfig memory);
function getTableHandLock(bytes32 tableId, bytes32 handId) external view returns (
    bool locked,
    bool settled,
    bool cancelled,
    uint16 rakeBps,
    uint256 totalLocked,
    uint256 rakeCapWei,
    address[] memory players,
    uint256[] memory amounts
);
function getTableEscrow(bytes32 tableId, address player) external view returns (
    uint256 unlocked,
    uint256 locked
);
```

**Webhook indexing remains the primary integration path.** These views
are for:

- **Sync reconciliation** — server crash recovery without replaying
  the entire event log.
- **External ecosystem consumers** — third-party indexers, ERC-8004
  attestation verifiers, dashboards rendering live state when the
  websocket source has lagged.
- **Cold-start backfill** — bootstrap a new indexer from a snapshot
  rather than crawling history.

For a single contest's full picture, `getContestRoster(contestId)`
plus `getContest(contestId)` is two RPC calls and gives you everything
a roster page needs.

## Migration / redeploy notes

The `joinContest(uint256,address)` selector has changed to
`joinContest(uint256,address,bytes32,bytes32)`. This is a breaking
change — existing in-flight contests on a previous deployment cannot
be migrated; the redeploy + DB wipe pattern (per `feedback_forge_zksync_wsl.md`
and the post-deploy sync script) is the upgrade path.

Session-key call policies must allow the new selector. The platform's
session-config factory in `chain.ts` already encodes
`joinContest(uint256,address,bytes32,bytes32)` as the allowed target
selector, so newly minted sessions will work end-to-end. Pre-existing
sessions minted against the old selector will reject the new tx and
the user will need to re-authorize (the `Session key rejected on chain`
error path already handles this prompt).
