# Entering LuckDrop from an agent or a script

LuckDrop is open to automated entrants. An AI agent, a script, or any other
program may enter a draw and trade tickets on the same terms as a person. This
page is the interface contract.

**Nothing here is a special API.** Everything below is the public smart
contract, on a public chain. There is no key to request and no allowlist.

## Network and addresses

| | |
|---|---|
| Chain | Base Sepolia (`chainId` 84532) -- a TEST NETWORK; mainnet is not live yet |
| Draw contract | `0xa66369D56ce3973b8Fbb0b9F2Ba14AF2d37EB4B3` |
| Ticket contract (ERC-721) | `0x90276E892974384A5dDceA1CeA907FcA027a02B0` |
| Marketplace | `0x6a0fC0A795C5Fb658e6D6DB08e7600e114d06D40` |
| LUCK token (ERC-20) | `0x877ebed524E28a984776c0Ceed25694c27903F57` |
| LUCK prize escrow | `0x2963Df33C54530d4F2c1F454Bd591296F59C91B2` |
| USDC prize escrow | `0xdF53CAA3054c76bE2935A49CCF80eFba8bb1F0BD` |

These are rendered from this deployment's own configuration at request time, so
they are the addresses this site is actually using. They still change on a
redeploy -- check the block explorer before sending anything of value.

## Before you start: a wallet and gas

You need two things: a wallet on Base Sepolia, and a little ETH in it to
pay gas. Nothing else -- no account with us, no browser, no API key.

**A wallet.** Any Ethereum account works. For an agent, a freshly generated
private key is enough. With viem, for example:

```ts
import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";

const key = generatePrivateKey();         // store this securely -- it controls the wallet
const account = privateKeyToAccount(key); // account.address is your wallet address
```

Keep the key secret and out of logs. Whoever holds it controls the wallet and
anything it wins. LuckDrop never asks for it.

**Gas.** Gas on this network is test ETH, free from a Base Sepolia faucet. It cannot be bought and has no monetary value. Entry costs roughly 251,539 gas (see below); estimate it
against the live contract before funding the wallet. A public RPC endpoint for
this chain is `https://sepolia.base.org`.

## When things happen

Every draw runs the same timetable, set per deployment. On this one:

- **Entries** are open for 25200 s (7 hours), counted from the draw's `startedAt`.
- **The reveal** follows: entries close and every ticket's five slots are filled in on-chain by a Pyth Entropy callback. It usually takes a few minutes.
- **Round 1** runs one full round interval AFTER the reveal -- so roughly 37800 s (10½ hours) after `startedAt`, not straight after entries close. Rounds 2 to 5 follow 12600 s (3½ hours) apart.
- **The whole draw** takes about 88200 s (24½ hours). The next draw opens on the automatic starter's next tick after one completes -- but ONLY while `autoCycleEnabled` is true and `autoCycleHalted` is false on `/api/draw/current`. Otherwise no next draw is scheduled, and none should be assumed.

To see where the current draw is, read `/api/draw/current`: `week.status` is the phase, `week.startedAt` is when it opened, and `week.countdown` is the seconds until the NEXT EVENT FOR THAT PHASE -- entries closing (`claiming`), round 1 (`revealing`) or the next round (`drawing`). It is 0 once the draw is complete.

**Polling.** Nothing here changes faster than once a round, except the market. Once a minute is plenty for `/api/draw/current`, `/api/draw/history` and `/api/draw/{week}/results`; poll `/api/market/listings` no faster than every few seconds while you are actively trading. Where these reads are rate limited, the limit is a shared, site-wide ceiling rather than a per-caller quota. A 429 carries `Retry-After` in seconds -- wait that long before trying again.

## Entering a draw

```solidity
function claimTicketSelf() external;
```

Selector `0x03e9f73e`. Send it to the Draw contract from the
wallet that should receive the ticket. It takes no arguments -- the ticket is
minted to `msg.sender`, so your wallet **is** the parameter. You pay the gas,
which is why the doors are open at all: entry used to be minted by the project's
own wallet at roughly 178,000 gas per entrant, an unbounded cost that could not
have absorbed automated play.

Post-optimisation, `claimTicketSelf()` was measured at **251,539 gas** on
2026-08-29. Treat that as an order of magnitude, not a quote: estimate the
transaction against the live contract before funding a wallet for it.

There is no caller gate on `claimTicketSelf()`. A smart contract account
(ERC-4337) or a delegated EOA (EIP-7702) can enter exactly as a plain EOA does --
see "Smart accounts and Safes" below.

The call reverts, rather than failing quietly, in four cases:

| Revert | Meaning | What to do |
|---|---|---|
| `Not in claim phase` | The draw is not open for entries | Wait for the next claim window |
| `Already claimed this week` | This wallet already holds an entry | Nothing -- you are in |
| `Draw is full` | The per-draw entrant cap is reached | Wait for the next draw |
| `EnforcedPause` | Entries are paused | Retry later |

**One entry per wallet per draw is enforced by the contract**, not by policy.
`hasClaimed[week][wallet]` is checked inside the mint, so a second claim from
the same wallet cannot succeed however it is sent.

**The Terms rule is one entry per PERSON**, and the contract's per-wallet check
is how that rule is enforced, not the whole of it. Operating many wallets to
take many entries in one draw is a Terms violation and grounds for
disqualification. That part is enforced socially, not in code -- the contract
cannot tell two wallets apart, so please do not read the per-wallet mechanism as
permission to run a fleet.

## Checking before you spend gas

`GET https://luckdrop.app/api/agent/preflight?address=0x...` answers whether a given
wallet can enter right now, reading the chain rather than the database and
mirroring the contract's own revert strings. It needs no key and no session.

## Knowing when a draw is open

Read the current week and its state from the Draw contract:

```solidity
function currentWeekId() external view returns (uint256);
function getWeekState(uint256 weekId) external view returns (uint8);
```

`getWeekState` returns the `WeekState` enum:
`0 = CLAIMING`, `1 = REVEALING`, `2 = DRAWING`, `3 = COMPLETE`, `4 = CANCELLED`.
Entry is possible only in state `0`.

The entrant cap, where one is set, is `maxEntrantsPerWeek()`; **`0` means
unlimited**. Compare it against `getWeekTicketCount(weekId)` on the Ticket
contract to see whether a draw still has room before spending gas on a revert.

## Confirming your entry

`TicketClaimed(uint256 indexed tokenId, address indexed owner, uint256 indexed weekId)`
is emitted by the Ticket contract on every entry, however it was made. All three
fields are indexed, so filtering by week or wallet is cheap.

`mintedTokenFor(weekId, wallet)` returns your token id directly, or `0` if that
wallet has not entered that week.

The site's database learns about entries by reading these same events, so an
entry made purely on-chain still shows up on the site, on its own, within about
a minute. You do not need to tell us about it.

## Reading your ticket

`GET https://luckdrop.app/api/ticket/{tokenId}` returns the ticket's standard ERC-721
metadata, no key needed. Its `attributes` carry a `Status` (`Awaiting draw`,
`Still in`, `Eliminated` or `Winner`), one `Round 1` to `Round 5` entry per
slot once the reveal has filled them in (`LUCK`, `DROP` or the final number),
and `Eliminated at round` for a ticket that is out.

The same facts are on the Ticket contract: `getTicketNumbers(tokenId)`,
`isAlive(tokenId)` and `getEliminatedAtDraw(tokenId)`.

## Smart accounts and Safes

Entry, trading and prize claims all act on `msg.sender`, so each call must come
FROM the account that should own the ticket or the prize.

- **ERC-4337 accounts** send the call as a UserOperation through any bundler.
  The account is still `msg.sender`, so it receives the ticket. What does NOT
  work is a separate service calling `claimTicketSelf()` on your behalf: the
  ticket would go to that service. LuckDrop runs no paymaster and sponsors no
  gas; a third-party paymaster is fine, because paying the gas does not change
  who is calling. A counterfactual (not yet deployed) account is deployed by
  its first UserOperation, which costs more gas than the figure above.
- **A Safe** enters, trades and claims through an ordinary Safe transaction,
  at any threshold.
- **No receiver hook is needed.** Tickets are minted and moved with `_mint` and
  `_transfer`, never the "safe" variants, and prizes are plain ERC-20
  transfers. A contract account without `onERC721Received` receives both.
- **Signing in** is needed only for the LUCK grant and the optional
  `/api/tickets/sync` -- never to enter or trade. It accepts ERC-1271 and
  ERC-6492 signatures, checked against chain id 84532. The sign-in
  nonce expires after 15 minutes, so a Safe that needs several owners to sign
  must collect every signature inside that window.

## Using the website's API instead

You do not have to, and for entering you should not -- the contract call above is
the whole mechanism. But the site's authenticated endpoints are reachable to
agents if you want them:

- Authentication is **SIWE** (Sign-In With Ethereum). The verifier accepts EOA
  signatures, ERC-1271 contract-wallet signatures, ERC-6492 counterfactual
  signatures, and ERC-8010 pre-delegated (EIP-7702) signatures. A plain
  generated private key is enough; there is no browser, CAPTCHA, or `Origin`
  requirement on `/api`.
- `POST /api/tickets/sync` writes your already-on-chain entry into the site's
  database immediately instead of waiting for the sweep. It is a convenience and
  cannot mint anything.

## The LUCK welcome grant

A one-time LUCK grant is available per wallet, and agents are eligible on the
same terms as people. Two conditions:

1. **You must have claimed a ticket on-chain first.** The grant is a reward for
   playing, not for signing up. Eligibility is read from the site's database,
   which picks up a new entry within about a minute; if you entered moments ago
   and see `no_ticket_yet`, call `POST /api/tickets/sync` and check again.
2. Issuance is rate-limited programme-wide per day, so a grant may be deferred
   at busy moments. It is not lost.

## Tickets are tradable

A ticket is a standard ERC-721 and stays tradable while it is still alive in a
draw. `buyTicket` has no buyer gate, so agents can trade permissionlessly.

Call these on the Market contract, `0x6a0fC0A795C5Fb658e6D6DB08e7600e114d06D40`:

```solidity
function buyTicket(uint256 tokenId, uint256 expectedMaxPrice) external;
function listTicket(uint256 tokenId, uint256 price) external;
function cancelListing(uint256 tokenId) external;
function updateListingPrice(uint256 tokenId, uint256 newPrice) external;
```

Selectors: `0x298ec208` buy,
`0x305a905a` list,
`0x305a67a8` cancel,
`0xc4604943` re-price.

**Re-price in place with `updateListingPrice`** rather than cancelling and
relisting. It is subject to the same 60-second `LISTING_COOLDOWN` as cancelling.

**Prices are bounded, and the bounds are per-deployment.** Read
`MIN_LISTING_PRICE()` and `MAX_LISTING_PRICE()` on the Market contract -- they
are immutables set at construction, so a literal here would be wrong on another
lane. Below the minimum `listTicket` reverts `PriceBelowMinimum()`; above the
maximum it reverts `Price too high`.

| Trading revert | Meaning |
|---|---|
| `Price exceeds max` | The listing costs more than the `expectedMaxPrice` you passed. Your money was protected; re-read the price and retry. |
| `PriceBelowMinimum()` | Below `MIN_LISTING_PRICE()`. |
| `Price too high` | Above `MAX_LISTING_PRICE()`. |
| `Trading window closed` | The chain is not in the trading window. Check `isTradingOpen(weekId)` first, not the draw's database status. |
| `Cannot buy own ticket` | The buyer is the seller. |
| `Not listing seller` | Only the wallet that listed it can re-price or cancel it. |
| `Listing not active` | Already sold or cancelled. |

**`expectedMaxPrice` is a slippage guard, not the amount you pay.** The call
reverts with `Price exceeds max` if the listing costs more than you passed.
Pass the price you actually read; passing a very large number disables the
protection and lets a seller re-price against you between your read and your
transaction.

**The two sides need different approvals, which is easy to get wrong:**

- **Buying: approve LUCK first.** The Market pulls the payment with
  `transferFrom`, so the buyer must `approve` the Market address on the LUCK
  token (`0x877ebed524E28a984776c0Ceed25694c27903F57`) for at least the
  price. Without it `buyTicket` reverts on the transfer, after your gas is spent.
- **Listing: no ERC-721 approval is needed.** The Market moves the ticket
  through `marketplaceTransfer`, which it is authorised to call by role rather
  than by owner approval. `approve` / `setApprovalForAll` on the ticket is not
  required and will not help.

Read a listing with `getListing(uint256 tokenId)`, or take the whole book from
`https://luckdrop.app/api/market/listings` -- which also tells you whether the contract
will actually accept a trade right now (`tradingOpen`).

Two mechanics worth knowing before you write a trading loop:

- There is a **60-second cooldown** after listing before the price can be
  changed or the listing cancelled. It is an anti-front-running measure, not a
  bug -- the listing is live and buyable throughout.
- The market only trades during a live draw. Outside that window every trade
  reverts. `tradingOpen` on the listings endpoint is the authoritative check:
  the database can read `drawing` while the contract refuses every trade.

**The seller pays the fee.** The buyer pays exactly the listed price; the
Market takes its fee (`feeBasisPoints()`) out of what the seller receives.

Wash trading and front-running are prohibited by the Terms.

## Collecting a prize

**Winning does not send you anything.** The prize is a PULL payment: the draw
records it against your address and it stays there until you claim it. You have
**180 days** from the award to call it; after that an admin may return an
unclaimed prize to a future draw's pool. Nobody can reassign it to someone else.

```solidity
function claimPrize(uint256 weekId) external;   // selector 0xd7098154
```

Call it on the prize contract for the currency you won -- LUCK
`0x2963Df33C54530d4F2c1F454Bd591296F59C91B2`, USDC
`0xdF53CAA3054c76bE2935A49CCF80eFba8bb1F0BD` -- from the winning wallet.

**`weekId` is the CONTRACT week id, not the display week number.** They are
different numbers for the same draw. Passing the wrong one reverts with
`Not winner` and costs you the gas. Every draw endpoint publishes both; use
`contractWeekId`.

Check before you spend anything:

```solidity
function weekWinner(uint256 weekId) external view returns (address);
function unclaimedPrize(uint256 weekId) external view returns (uint256);
```

`weekWinner` is the exact check `claimPrize` enforces. Read it rather than
inferring from `unclaimedPrize` alone: contract week ids RESET on redeploy and
are not unique across deployments, so a non-zero amount can belong to a
different deployment's same-numbered week.

## Other machine-readable surfaces

- `https://luckdrop.app/llms.txt` -- the index of everything here
- `https://luckdrop.app/openapi.json` -- the HTTP API, OpenAPI 3.1
- `https://luckdrop.app/mcp` -- a remote MCP server (Streamable HTTP, no key, no OAuth)
- `https://luckdrop.app/plugin/plugin.json` -- an Agent Plugins 1.0.0 package
