# DROPS Agent Playbook

DROPS is an NFT marketplace on XRPL EVM. Deploy ERC-721 collections, mint NFTs with permanent Arweave storage, and trade on a secondary marketplace with royalties. All operations are agent-friendly via CLI or direct contract calls.

## Quickest Start: `npx drops-market`

The DROPS CLI covers the full NFT lifecycle. Zero config for reads, just `WALLET_PRIVATE_KEY` for writes.

```bash
# 1. Generate a wallet
npx drops-market wallet new

# 2. Set your key (same key signs EVM txns AND Arweave uploads)
export WALLET_PRIVATE_KEY=0x...

# 3. Check connection and balance
npx drops-market status
```

**Stop here if your balance is 0.** A new wallet has no XRP and every command below
will fail. See [Funding your wallet](#funding-your-wallet) — it is the next step, and
there is no way to skip it.

```bash
# 4. Once funded: create a collection, mint an NFT, list it for sale
npx drops-market create collection --name "My Art" --symbol "ART" --price 1 --supply 100 --royalty 5 --image ./banner.png
npx drops-market mint 0xCOLLECTION --image ./art.png --name "Piece #1" --description "My first NFT"
npx drops-market list-nft 0xCOLLECTION 1 10

# Buying is the other side of the trade — find someone else's listing first
npx drops-market browse
npx drops-market buy <listingId>
```

**All commands:** Run `npx drops-market help`. Use `--json` for structured output.

> **The default network is mainnet, and mainnet spends real XRP.**
>
> DROPS is a mainnet product — the website only displays mainnet, so the CLI defaults there
> too and what you create is immediately visible on drops.market. Deploying costs **1 XRP plus
> 0.01 XRP per declared token** — so 1.01 XRP for a one-of-one, 2 XRP for 100 items, 101 XRP for
> 10,000 — plus gas, plus whatever mint price you set.
>
> - **Testing, or don't want to spend?** Pass `--testnet` (or `DROPS_CHAIN=testnet`). The
>   XRP is free there, but the **same deploy fee still applies** — the testnet factory
>   charges the same, in test XRP, so a testnet wallet still needs a real balance before
>   it can deploy. Testnet is also **headless**: it is not indexed by drops.market, so
>   nothing you create there appears on the site. Verify from the terminal instead
>   (see below).
> - Every deploy prints its network before sending, and `--json` includes
>   `chain` / `visibleOnSite` / `viewUrl` so you can assert on it programmatically.
>
> Storage is on us either way: your art and metadata go to Arweave at DROPS' expense and the
> returned URL works immediately, on both networks. You only need XRP for on-chain fees.

## Funding your wallet

A wallet from `drops-market wallet new` starts empty. Uploads are free (DROPS pays for
Arweave), but **every on-chain action needs XRP for gas**, and deploying costs a platform fee
on top — **1 XRP + 0.01 XRP per declared token**. Check what you have first:

```bash
npx drops-market balance --json
```

### Mainnet (default) — no faucet exists

Real XRP, so nobody gives it away. **An agent cannot fund itself on mainnet.** Print your
address and ask whoever runs you to send XRP to it on XRPL EVM (chain 1440000).

| What you want to do | Minimum balance |
|---|---|
| Deploy a one-of-one (`--supply 1`) | **~1.02 XRP** (1.01 fee + gas) |
| Deploy a 100-item collection | **~2.01 XRP** (2 fee + gas) |
| Deploy a 10,000-item drop | **~101 XRP** (101 fee + gas) |
| Mint / list / buy / offer / bid | gas only, plus the price of whatever you're buying |
| Comfortable working balance | deploy fee for your supply, plus 1-2 XRP |

Gas is cheap: at the observed mainnet price (~0.79 gwei) a deploy costs about **0.002 XRP**
of gas and a mint about **0.0002 XRP**. The deploy fee is essentially the whole cost, and it
scales with the supply you declare — compute it exactly with `deployFeeFor(maxSupply)` (below)
or read it from `drops status`.

XRP reaches XRPL EVM either by bridging from the XRPL mainchain or by withdrawing from an
exchange that supports the XRPL EVM network directly. Both need a human. Confirm arrival
with `balance` before spending — do not assume a transfer landed.

### Testnet (`--testnet`) — faucet, but expect a human in the loop

Testnet XRP is free and worthless — but you still need some. The deploy fee is charged on
testnet exactly as on mainnet, just in test XRP, so an unfunded testnet wallet cannot deploy
either. The faucet is:

**https://faucet.xrplevm.org** — up to 90 XRP per claim, which covers a 10,000-item drop's
fee only just, and dozens of small collections.

Caveat that matters for automation: the faucet is **bot-gated**. Automated clients get an
HTTP 429 with a browser challenge instead of funds, so a fully autonomous agent generally
**cannot** claim from it. Options, in order of preference:

1. Ask your operator to open the faucet in a browser and paste your address.
2. Ask in the `#faucet` channel of the XRPL EVM Discord (community links are on
   <https://docs.xrplevm.org>).
3. Reuse an already-funded testnet key instead of generating a fresh one.

Remember testnet is headless — nothing you deploy there appears on drops.market.

## Two Collection Types

| | Collection | Drop |
|---|---|---|
| Use case | Curated art (1-100 items) | Large editions (100-10K+) |
| URI pattern | Per-token (unique metadata per NFT) | baseURI + tokenId (batch metadata) |
| Art upload | Individual at mint time | Bulk upload before deploy |
| **Token IDs** | **Sequential — 1, 2, 3, …** | **Random within `[1, maxSupply]`** |
| Features | -- | maxPerWallet, blind mint, reveal |

### Token IDs: do not assume sequential

This trips up agents constantly, so be explicit about it:

- **DropsCollection** mints `tokenId = ++totalMinted`. The Nth token minted is token N.
  After 3 mints you own tokens 1, 2, 3.
- **DropsDrop** draws a random unused ID from the whole `[1, maxSupply]` range on every
  mint (a lazy Fisher-Yates shuffle in `_drawRandomToken`). Every ID is unique and the
  set is eventually complete, but **the order is unpredictable and there are gaps** until
  the drop sells out.

A live example — the Nanokins drop has 7 of 250 minted, and the IDs are:

```
8, 29, 46, 49, 133, 137, 149
```

So on a Drop, `tokenURI(1)` reverts with `ERC721NonexistentToken(1)` even though 7 tokens
exist. Never derive a Drop's token IDs from `totalMinted` — always read the actual IDs
(see [Listing a Drop's real token IDs](#listing-a-drops-real-token-ids)).

## Networks

### Testnet (Chain ID: 1449000) — opt in with `--testnet`, **not shown on drops.market**

| Resource | Value |
|----------|-------|
| RPC | `https://rpc.testnet.xrplevm.org` |
| Explorer | `https://explorer.testnet.xrplevm.org` |
| Faucet | `https://faucet.xrplevm.org` (up to 90 XRP; bot-gated — see above) |
| Native currency | XRP (free from the faucet, no real value) |
| Deploy fee | **The same as mainnet** — 1 XRP + 0.01 per token, in test XRP. Only the XRP is free |
| Gas estimation | Works for deploys (measured 1.55M for a 1/1, 2026-07-27). Set an explicit limit only if a specific call fails to estimate |
| drops.market UI | **No.** Headless only — verify from the terminal/explorer |

### Mainnet (Chain ID: 1440000) — default, and what drops.market displays

| Resource | Value |
|----------|-------|
| RPC | `https://rpc.xrplevm.org` |
| Explorer | `https://explorer.xrplevm.org` |
| Faucet | None. Real XRP — your operator has to fund you |
| Native currency | XRP (real) |
| Gas estimation | Works — auto-estimate is fine |
| drops.market UI | Yes — collection + token pages, browse, profile |

### Verifying testnet work without the UI

Testnet is fully functional on-chain; it just has no web front end. Everything you'd
normally check on the site, you can check from the terminal:

```bash
# Collection details (name, supply, mint price, owner, mint status)
npx drops-market info 0xCOLLECTION

# Tokens minted in a collection (Collections only — see the Drop note below)
npx drops-market tokens 0xCOLLECTION

# Everything you've created
npx drops-market list --creator 0xYOUR_ADDRESS
```

Plus:
- **Block explorer** — `https://explorer.testnet.xrplevm.org/address/0xCOLLECTION` shows the
  contract, transactions, and token transfers.
- **Your artwork and metadata** — the Arweave URL returned at upload works right away in any
  browser, on either network. `tokenURI(<id>)` returns the metadata JSON URL for any minted token.

If you need to *see* the full marketplace experience — grid, collection page, trading UI —
deploy to mainnet. That is currently the only network with a web front end.

## Contracts

Platform contracts (Factory, Market, Offers, Auction) are **UUPS upgradeable proxies** owned by deployer `0x47f4A47f61c5d10fc24307eC54988046B3073F5E` — proxy addresses are permanent across upgrades. DropsLens is an immutable stateless view contract. User-deployed NFT contracts (DropsCollection, DropsDrop) are immutable.

### Mainnet (chain 1440000) — deployed 2026-05-11, block 5,808,802

| Contract | Address | Purpose |
|----------|---------|---------|
| DropsFactory | `0x00844CaE1ecfd0b73356f6d9cB7A7e421EC9c7A8` | Deploy collections + drops, mint, registry |
| DropsMarket | `0xb5D697C249f219E9D47969bCcEbE7E6A65291ccA` | List/buy/cancel, 1% fee + royalties |
| DropsOffers | `0x917AA02C718EFbF0621FD72Dd39E70C074b6732d` | Offer/bid with escrow + expiration |
| DropsAuction | `0x4c73c939737Eaa3dAC015528cF967D52C9B344b7` | Timed auctions, anti-snipe, reserve prices |
| DropsLens | `0x46E0bf48a18f44Dc25B74EdDA2795f7Baa6934af` | Batch view aggregator (single eth_call) |

### Testnet (chain 1449000) — deployed 2026-03-28, block 6,101,039

| Contract | Address | Purpose |
|----------|---------|---------|
| DropsFactory | `0x46E0bf48a18f44Dc25B74EdDA2795f7Baa6934af` | Deploy collections + drops, mint, registry |
| DropsMarket | `0x1689e3D5CfF83C865934bF9504940b08ca358dBF` | List/buy/cancel, 1% fee + royalties |
| DropsOffers | `0x9DB977b941fDDdCC3Ab194e3cC83F2cDAc562C26` | Offer/bid with escrow + expiration |
| DropsAuction | `0x72BC803a23BBBB2f5B1e823a9b57F6F4a430dFAA` | Timed auctions, anti-snipe, reserve prices |
| DropsLens | `0x7895DefEf770B588b46329E62E7d763Ff175dF5a` | Batch view aggregator (single eth_call) |

Code examples below use **mainnet** addresses + mainnet RPC — the network drops.market displays, and the CLI default. To run against testnet instead, swap in the testnet addresses from the table above plus `https://rpc.testnet.xrplevm.org`, or just use the CLI with `--testnet` — the fees are the same there, only the XRP is worthless. Note testnet needs an explicit `--gas-limit` (estimation is broken there); mainnet estimates fine. See [Gas limits](#gas-limits) for the floors per call.

## Fee Model

| Action | Fee | Collected by |
|--------|-----|-------------|
| Deploy collection/drop | 1 XRP + 0.01 XRP per declared token | DropsFactory |
| Primary mint | 2.5% of mint price | DropsFactory |
| Secondary sale | 1% platform fee | DropsMarket |
| Creator royalty | 0-10% (EIP-2981) | DropsMarket (on buy) |

**Every fee above is identical on testnet**, charged in test XRP — the testnet factory is
the same contract with the same pricing. Testnet makes the XRP free, not the
fees. Confirm for yourself with
`cast call <factory> "deployFeeFor(uint256)(uint256)" 100 --rpc-url https://rpc.testnet.xrplevm.org`.
(`deployFee()` still exists but is a legacy flat fallback, unused while
supply-scaled pricing is configured — do not quote a deploy from it.)

## CLI Command Reference

### Wallet & Status

```bash
npx drops-market wallet new          # Generate new wallet keypair
npx drops-market status              # Network info, contract addresses, balance
npx drops-market balance             # XRP balance
npx drops-market --version           # CLI version
npx drops-market help                # All commands
```

### Upload to Arweave

**Storage is included — free to you.** Uploads go through `api.drops.market`, which
holds the Arweave credits and pays for permanent storage. The CLI signs a lightweight
auth message with your `WALLET_PRIVATE_KEY` (no gas, no separate Arweave wallet, no
Turbo credits). You need XRP only for gas, deploy, and mint fees. Images are compressed
to WebP server-side automatically.

```bash
npx drops-market upload image ./photo.png       # Upload single image -> Arweave URI
npx drops-market upload metadata ./meta.json    # Upload metadata JSON -> Arweave URI
npx drops-market upload batch ./images/         # Upload directory + manifest -> baseURI
```

Limits: **10 MB per file**, **50 files per request** (the CLI chunks larger directories
automatically). Fair-use quota per wallet: 500 MB, plus 500 MB for each collection you've
deployed (ample for a full 10K-item drop). Bring-your-own storage with `--uri` skips uploads
entirely and preserves your own Arweave attribution. See [Upload failure modes](#upload-failure-modes)
for what the errors mean.

### Create Collections

```bash
# Collection (per-token URI, 1-100 items)
npx drops-market create collection \
  --name "My Art" --symbol "ART" \
  --price 1 --supply 100 --royalty 5 \
  --image ./banner.png

# Drop (baseURI, 100-10K+ items, batch upload)
npx drops-market create drop \
  --name "Genesis" --symbol "GEN" \
  --supply 1000 --price 0.5 --royalty 5 \
  --images ./images/ --max-per-wallet 5

# With pre-uploaded URI instead of image
npx drops-market create collection \
  --name "My Art" --symbol "ART" \
  --price 1 --supply 100 \
  --contract-uri "https://arweave.net/abc"
```

Both are priced the same way — 1 XRP + 0.01 per declared token — on **either** network, in
test XRP. Add `--dry-run` to see exactly what would be sent without spending anything.

### Mint

```bash
# Upload + mint in one step (Collection)
npx drops-market mint 0xCOLLECTION \
  --image ./art.png --name "Piece #1" --description "My first NFT"

# With pre-uploaded metadata URI
npx drops-market mint 0xCOLLECTION --uri "https://arweave.net/xyz"

# Batch mint on a Drop
npx drops-market mint-drop 0xDROP --quantity 5
```

Minting into a collection **you created** uses the free `creatorMint` path and works
immediately — you do NOT need to `admin open-mint` first, and you pay no mint fee.
Public mints (by others) require the creator to have opened minting.

**`mint-drop` gives you random token IDs.** A Drop assigns each token a random unused ID
from `[1, maxSupply]`, so `--quantity 5` does not give you tokens 1-5 — it gives you five
unpredictable IDs scattered across the range. The command's JSON output returns `quantity`,
not the IDs. To find out what you actually got, read them back after the tx confirms:

```bash
# The reliable way — the indexer knows the real IDs (mainnet only)
curl -s "https://api.drops.market/users/0xYOUR_ADDRESS/tokens" | jq .
```

### Browse & Read

```bash
npx drops-market info 0xADDRESS            # Collection/drop details
npx drops-market list                      # All collections on platform
npx drops-market list --creator 0xADDRESS  # Filter by creator
npx drops-market tokens 0xCOLLECTION       # List tokens (see caveat)
npx drops-market tokens 0xCOLLECTION --owner 0xADDRESS
npx drops-market browse                    # Active marketplace listings
npx drops-market browse --limit 20 --offset 0
```

#### Listing a Drop's real token IDs

`drops tokens` scans IDs `1..totalMinted`. That is correct for a **Collection** (IDs are
sequential) but **wrong for a Drop**, whose IDs are random — a Drop with 7 minted tokens
numbered 8/29/46/… reports an empty list, because IDs 1-7 do not exist. For Drops, read
the indexer instead (mainnet only):

```bash
curl -s "https://api.drops.market/collections/0xDROP/tokens" | jq '.data'
# -> [{"tokenId":8,"owner":"0x4e2e…"}, {"tokenId":29,"owner":"0x4e2e…"}, …]
```

On testnet, where there is no indexer, enumerate with `ownerOf` across the full
`[1, maxSupply]` range and ignore reverts.

### Secondary Market

```bash
npx drops-market list-nft 0xCOLLECTION 1 10    # List token #1 for 10 XRP
npx drops-market buy 37                          # Buy listing #37
npx drops-market cancel-listing 37               # Cancel your listing
```

#### Listing IDs are global, not per-collection

There is **one counter for the whole marketplace**. IDs start at 1 and increment across
every collection and every seller, so the first listing on *your* collection may be #37.
Nothing about a listing ID tells you which collection or token it belongs to — read
`collection` and `tokenId` off the listing itself.

`list-nft --json` returns the `listingId` it just created; `browse` shows which IDs are
still active. Don't guess one, and don't assume your second listing is #2.

#### One listing per token

A token has at most one listing that can execute. Creating a listing **retires any
previous listing for the same token** — whoever made it, whatever the price — and emits
`ListingSuperseded(listingId, replacedBy)` so indexers can drop it.

Why: ERC-721 gives the marketplace no transfer hook, so a listing cannot observe the token
leaving the seller. It does not die — it goes dormant, and becomes executable again at its
original price if the token ever comes back. Retiring the old listing as soon as the token
is listed again breaks that cycle.

What a dead listing looks like when you try to buy it:

| Situation | `buy` reverts with |
|---|---|
| Superseded by a newer listing, already sold, or cancelled | `ListingNotActive` |
| Created before this rule shipped, and a newer listing now owns the token | `ListingStale` |

`activeListingOf(collection, tokenId)` names the listing allowed to execute right now.
`buy` and `cancel` clear it back to 0, and 0 means "nothing tracked" — either the token
isn't listed, or its only listing predates the rule.

```bash
cast call 0xb5D697C249f219E9D47969bCcEbE7E6A65291ccA \
  "activeListingOf(address,uint256)(uint256)" 0xCOLLECTION_ADDRESS 1 \
  --rpc-url https://rpc.xrplevm.org
```

`browse` filters on the listing's own `active` flag, so a pre-rule listing that has been
displaced can still show up there and then revert `ListingStale` when you buy it.
`activeListingOf` is the authority; treat `browse` as a candidate list.

Through the CLI you see this without decoding selectors:

- `list-nft` adds `supersededListingId` to its `--json` output (and a "Replaced #N" line
  otherwise) whenever the new listing retired an older one. Absent means nothing was
  retired.
- `buy` checks `activeListingOf` before spending gas and fails with
  `LISTING_SUPERSEDED`, carrying `supersededBy` — the listing ID that displaced yours —
  plus `currentPriceXRP` when that replacement is still live. Buy that ID instead.

**Cancel your listing before you move the token.** The rule only fires when someone
creates a *new* listing for that token. If the token leaves and comes back by any other
route — you accept an offer, an auction settles, you move it to another wallet of your
own — nothing supersedes the old listing and it is buyable again at its old price. List at
100, take a 50 offer instead, buy back at 150, and the 100 listing is still sitting there
for anyone to take. `drops cancel-listing <id>` costs gas only.

### Offers

```bash
npx drops-market offer 0xCOLLECTION 1 --amount 5 --duration 7d
npx drops-market accept-offer 1
npx drops-market cancel-offer 1
```

### Auctions

```bash
npx drops-market auction create 0xCOLLECTION 1 --start-price 5 --reserve 20 --duration 3d
npx drops-market auction bid 1 --amount 25
npx drops-market auction settle 1
npx drops-market auction cancel 1
```

### Creator Admin

```bash
npx drops-market admin open-mint 0xCOLLECTION
npx drops-market admin close-mint 0xCOLLECTION
npx drops-market admin set-price 0xCOLLECTION 2
npx drops-market admin reveal 0xDROP --base-uri "https://arweave.net/manifest/"
npx drops-market admin withdraw 0xCOLLECTION
```

#### `admin reveal` is not idempotent — it is a one-way door

`DropsDrop.reveal()` has **no on-chain guard**. It overwrites the drop's base URI
unconditionally, every time it is called. A second reveal silently repoints the art and
metadata of **every token in the drop**, including ones already sold, and there is no
undo — nothing reverts, nothing warns you on-chain, and the old URI is gone.

The CLI refuses to re-reveal a drop that already reads `revealed: true`, failing with
`ALREADY_REVEALED` (`retryable: false`). `--force` sends it anyway:

```bash
npx drops-market admin reveal 0xDROP --base-uri "https://arweave.net/manifest/" --force
```

Only pass `--force` if repointing the whole drop is genuinely what you want. And never
put reveal in a retry loop: if the transaction times out, read `revealed` back (or
`drops info 0xDROP`) before sending another — the first one may have landed.

Two more ways a reveal goes wrong quietly:
- The contract builds `<baseURI><tokenId>.json`, so the base URI **must end in `/`**.
  Miss it and every `tokenURI` is malformed. The CLI echoes the resulting pattern after
  a reveal so you can eyeball it.
- `create drop --images` already reveals for you right after deploying. Only `--blind`
  drops (and drops deployed without art) need a manual reveal.

#### Before reveal, every token returns the same metadata

`tokenURI(id)` on an unrevealed drop returns `hiddenMetadataURI` — **the identical URI
for every token**, not a per-token file. That is what a blind mint is: one placeholder
name and image for the whole supply until you reveal. If `tokenURI(8)` and `tokenURI(29)`
come back the same, nothing is broken; check `revealed` (in `drops info`) before you go
looking for a manifest bug.

That URI often *looks* per-token. On a non-blind `create drop --images`, the CLI fills
the field with `<baseURI>1.json` — token 1's metadata, reused as a throwaway placeholder
— because the same command reveals moments later and it is never shown. Seeing
`hiddenMetadataURI` end in `/1.json` is expected, not a sign the drop is stuck on
token 1.

### Every flag, by command

| Command | Flags |
|---------|-------|
| `create collection` | `--name` `--symbol` `--supply` (**all required**, supply >= 1); `--price` `--royalty`; `--image` (banner, 16:9) `--thumbnail` (1:1) or `--contract-uri` |
| `create drop` | `--name` `--symbol` `--supply` (**all required**, supply >= 1); `--price` `--royalty` `--max-per-wallet`; `--images <dir>` `--names`; `--blind` `--placeholder-image`; `--image` `--thumbnail` or `--contract-uri`; `--hidden-uri` |
| `mint` | `--uri` or (`--image` + `--name`); `--description` `--attributes` `--to` |
| `mint-drop` | `--quantity` (default 1) `--to` |
| `upload batch` | `--names` |
| `list` | `--creator` |
| `tokens` | `--owner` |
| `browse` | `--limit` (default 20) `--offset` (default 0) |
| `offer` | `--amount` (required) `--duration` (default `7d`) |
| `auction create` | `--start-price` (required) `--reserve` `--duration` (default `3d`) |
| `auction bid` | `--amount` (required) |
| `admin reveal` | `--base-uri` (required); `--force` (re-reveal an already-revealed drop) |

Details worth knowing:

- **`--to`** mints to an address other than your own. Defaults to your wallet.
- **`--attributes`** takes either shape: `'{"Background":"Blue"}'` shorthand, or the ERC-721
  standard `'[{"trait_type":"Background","value":"Blue"}]'`.
- **`--image` vs `--images`** — singular is the collection banner; plural is the directory of
  per-token art for a drop. Easy to mistype, very different results.
- **`--thumbnail`** adds a square (1:1) thumbnail next to the 16:9 `--image` banner. DROPS
  never crops or pads your art, so supply both if you want it to look right everywhere.
- **`--names`** maps filenames to titles for a batch. Either a JSON object
  (`{"1.png": {"name":"…","description":"…"}}`) or a CSV with a header row
  (`filename,name,description`).
- **`--blind`** deploys a drop whose tokens all show placeholder metadata until you
  `admin reveal`. Pair it with `--placeholder-image`, or a blank placeholder is generated.
  Every pre-reveal token returns that one URI — see
  [Before reveal, every token returns the same metadata](#before-reveal-every-token-returns-the-same-metadata).
- **`--force`** applies only to `admin reveal`, and only to a drop that is already
  revealed. It overwrites the metadata for the entire drop and cannot be undone.
- **Durations** accept `30s` / `10m` / `12h` / `7d`, or a bare number of seconds.
- **Prices and royalties are human units** — `--price 1` is 1 XRP, `--royalty 5` is 5%
  (max 10). Only raw `cast` calls need wei and basis points.

### Global flags

| Flag | Effect |
|------|--------|
| `--json` | Structured output (see below). Works on every command. |
| `--mainnet` / `--testnet` | Pick the network. Beats `DROPS_CHAIN`. |
| `--dry-run` | Print what would be sent; send nothing. |
| `--help` | Usage for the command you attached it to. |
| `--version` | CLI version. |

Both `--flag value` and `--flag=value` are accepted.

## JSON Output Shapes

Every command supports `--json`. Success is wrapped in a fixed envelope:

```json
{
  "ok": true,
  "version": "0.4.0",
  "chain": "mainnet",
  "chainId": 1440000
}
```

…with the command's own fields merged in alongside. **Check `ok` first** — treat anything
that is not `ok: true` as a failure. Note that numbers which can exceed 2^53 (wei amounts,
token counts) are returned as **strings**.

### Failures

Errors are printed to **stdout** in `--json` mode (so `JSON.parse(stdout)` sees them) and
the process exits non-zero:

```json
{
  "ok": false,
  "version": "0.4.0",
  "chain": "mainnet",
  "error": {
    "code": "BAD_PRIVATE_KEY",
    "message": "WALLET_PRIVATE_KEY is not a valid 32-byte hex key",
    "hint": "Expected 64 hex characters, optionally 0x-prefixed. `drops wallet new` generates one."
  }
}
```

`error.code` is stable and safe to branch on; `error.message` and `error.hint` are for
humans. Codes you will hit early: `NO_PRIVATE_KEY`, `BAD_PRIVATE_KEY`.

### Read commands (shapes below are real output, captured from mainnet)

`status` — no wallet fields unless a key is set:

```json
{
  "ok": true, "version": "0.4.0", "chain": "mainnet", "chainId": 1440000,
  "rpc": "https://rpc.xrplevm.org",
  "explorer": "https://explorer.xrplevm.org",
  "contracts": {
    "factory": "0x00844CaE1ecfd0b73356f6d9cB7A7e421EC9c7A8",
    "market": "0xb5D697C249f219E9D47969bCcEbE7E6A65291ccA",
    "offers": "0x917AA02C718EFbF0621FD72Dd39E70C074b6732d",
    "auction": "0x4c73c939737Eaa3dAC015528cF967D52C9B344b7",
    "lens": "0x46E0bf48a18f44Dc25B74EdDA2795f7Baa6934af"
  },
  "wallet": { "address": "0x…", "balance": "0" }
}
```

`info <address>` — `maxPerWallet` and `revealed` appear only for Drops:

```json
{
  "ok": true, "version": "0.4.0", "chain": "mainnet", "chainId": 1440000,
  "address": "0x41618438c680D646BeaC253fda23d312B4629565",
  "type": "Drop",
  "name": "Nanokins",
  "symbol": "NANO",
  "totalMinted": "7",
  "maxSupply": "250",
  "mintPrice": "15000000000000000000",
  "mintPriceXRP": "15.00",
  "mintOpen": true,
  "owner": "0xd2DCc6e80053e0C833Af0b5e46f5d0057a16D74F",
  "maxPerWallet": "15",
  "revealed": true,
  "explorer": "https://explorer.xrplevm.org/address/0x41618438c680D646BeaC253fda23d312B4629565"
}
```

`list`:

```json
{
  "ok": true, "version": "0.4.0", "chain": "mainnet", "chainId": 1440000,
  "total": 3,
  "collections": [
    { "address": "0x41618438c680D646BeaC253fda23d312B4629565", "type": "Drop" },
    { "address": "0x3CFf0C2D378d89B169d86cd20F224ef62C6FD8C1", "type": "Collection" }
  ]
}
```

`list --creator 0x…` returns `{ "creator": "0x…", "collections": ["0x…"] }` — a flat
address array, not objects.

`tokens <collection>`:

```json
{
  "ok": true, "version": "0.4.0", "chain": "mainnet", "chainId": 1440000,
  "collection": "0x41618438c680D646BeaC253fda23d312B4629565",
  "tokens": [ { "tokenId": 1, "owner": "0x…" } ]
}
```

`browse`:

```json
{
  "ok": true, "version": "0.4.0", "chain": "mainnet", "chainId": 1440000,
  "listings": [
    {
      "listingId": "1",
      "seller": "0x…",
      "collection": "0x…",
      "tokenId": "1",
      "price": "10000000000000000000",
      "priceXRP": "10.00"
    }
  ]
}
```

`wallet new` → `{ …envelope, "address": "0x…", "privateKey": "0x…" }`. The key is the
whole point of the command, so it is in stdout — do not log it. A warning also goes to
stderr.

#### Budgeting a deploy from `status --json`

Deploy price is **not a constant** — it scales with the supply you declare. `status`
carries the live pricing under `stats`, so you can compute your cost before spending:

```jsonc
"stats": {
  "deployPricing": "supply-scaled",   // or "flat" — see below
  "baseFeeXRP": "1.00",               // flat launch component
  "perTokenFeeXRP": "0.0100",         // per declared token
  "deployFloorXRP": "1.01",           // cheapest possible deploy (a 1/1)
  "deployCostExamples": { "1": "1.01", "100": "2.00", "1000": "11.00", "10000": "101.00" },
  "deployFee": "10000000000000000000", // LEGACY flat fee (10 XRP) — see warning
  "mintFeeBps": "250"
}
```

**Do not budget from `deployFee`.** It is the legacy flat fallback and is only the real
price when `deployPricing` is `"flat"` (i.e. supply-scaled pricing is unconfigured on that
chain). While `deployPricing` is `"supply-scaled"`, your cost is
`baseFee + perTokenFee x maxSupply` — or just read `deployFeeFor(maxSupply)` off the
factory. `balance --json` reports `canDeploy` against the **floor** (a 1/1), so it being
`true` does not mean you can afford a 10,000-item drop.

Cheapest way to check a specific deploy without spending anything:

```bash
npx drops-market create drop --name X --symbol X --supply 5000 --dry-run --json --mainnet
# -> "deployFeeXRP": "51.00", plus a balance verdict
```

### Write commands

> These shapes are read from the CLI source (`npm-package/cli.js`), **not** observed —
> running them spends real XRP. Field names are exact; treat the values as illustrative.

| Command | Fields merged into the envelope |
|---------|--------------------------------|
| `create collection` | `hash`, `collectionAddress`, `explorer`, `contractURI`, `maxSupply`, `deployFee` (wei), `deployFeeXRP`, `visibleOnSite`, `viewUrl` |
| `create drop` | `hash`, `dropAddress`, `explorer`, `baseURI`, `contractURI`, `hiddenMetadataURI`, `maxSupply`, `deployFee` (wei), `deployFeeXRP`, `visibleOnSite`, `viewUrl` |
| `mint` | `hash`, `tokenId`, `to`, `uri`, `explorer`, `creatorMint`, `visibleOnSite`, `viewUrl` |
| `mint-drop` | `hash`, `quantity`, `to`, `explorer` — **no token IDs**, they are random |
| `list-nft` | `hash`, `listingId`, `collection`, `tokenId`, `price`, `priceWei`, `priceXRP`, `explorer`, plus `supersededListingId` when this listing retired an older one |
| `buy` | `hash`, `listingId`, `collection`, `tokenId`, `price`, `priceWei`, `priceXRP`, `seller`, `buyer`, `explorer` |
| `cancel-listing` | `hash`, `listingId`, `collection`, `tokenId`, `priceWei`, `priceXRP`, `explorer` |
| `offer` | `hash`, `collection`, `tokenId`, `amount`, `duration`, `explorer` |
| `accept-offer` / `cancel-offer` | `hash`, `offerId`, `explorer` |
| `auction create` | `hash`, `collection`, `tokenId`, `startPrice`, `reserve`, `duration`, `explorer` |
| `auction bid` | `hash`, `auctionId`, `amount`, `explorer` |
| `auction settle` / `auction cancel` | `hash`, `auctionId`, `explorer` |
| `upload image` / `upload metadata` | `txId`, `url` |
| `upload batch` | `baseURI`, `manifestTxId`, `tokenCount`, `images` (filename → txId map) |
| `admin open-mint` / `close-mint` | `hash`, `address`, `mintOpen`, `explorer` |
| `admin set-price` | `hash`, `address`, `price`, `explorer` |
| `admin reveal` | `hash`, `address`, `baseURI`, `revealed`, `tokenURIPattern`, `sampleTokenURI`, `explorer` |
| `admin withdraw` | `hash`, `address`, `explorer` |

`visibleOnSite` is `true` only on mainnet; `viewUrl` is the drops.market page (or `null`
on testnet). On deploys, `hash` is always present but `collectionAddress` / `dropAddress`
can be `null` if the event could not be decoded — fall back to the explorer link.

## Direct Contract Calls

For agents that prefer raw contract interaction over CLI.

### Gas limits

Gas estimation works on **both** networks — measured 2026-07-27: 1.55M (testnet) and
1.57M (mainnet) for a 1/1 deploy, 1.89M for a 1,000-item drop. An explicit `--gas-limit`
is optional. One caveat that matters: on a call whose success depends on `value` (any
deploy), a hardcoded limit **skips** estimation, so an underpaid deploy is broadcast and
reverts on-chain instead of failing before you send it. Prefer estimation there. Use these floors. A blanket 1,000,000 for
everything — which this page used to advise — is **well under what a deploy needs**: the
transaction runs out of gas, fails, and still charges you for the gas it burned.

| Call | Minimum `--gas-limit` |
|------|-----------------------|
| `createCollection`, `createDrop` | **3,000,000** (real mainnet deploys billed ~2.5M) |
| `mint`, `creatorMint`, `mintDrop` | 1,000,000 |
| `list`, `buy`, `cancel`, `makeOffer`, `acceptOffer`, `createAuction`, `bid` | 1,000,000 |
| `setApprovalForAll` | 500,000 |

Don't overshoot wildly either — set a limit close to the floor rather than passing
something enormous "to be safe".

### Deploy a Collection

```bash
cast send 0x00844CaE1ecfd0b73356f6d9cB7A7e421EC9c7A8 \
  "createCollection(string,string,uint256,uint256,uint96,string)" \
  "My Art" "ART" 1000000000000000000 100 500 "https://arweave.net/CONTRACT_URI" \
  --rpc-url https://rpc.xrplevm.org \
  --private-key $WALLET_PRIVATE_KEY \
  --gas-limit 3000000 \
  --value 2ether     # deployFeeFor(100). READ IT, do not assume — see below
```

The `--value` is **not a constant**. Deploy price is `1 XRP + 0.01 per declared
token`, so quote it for your own supply first and send exactly that:

```bash
cast call 0x00844CaE1ecfd0b73356f6d9cB7A7e421EC9c7A8 \
  "deployFeeFor(uint256)(uint256)" 100 --rpc-url https://rpc.xrplevm.org
# -> 2000000000000000000  (2 XRP)
```

Overpaying is refunded; underpaying reverts `InsufficientDeployFee()`
(`0x0017ce36`). `maxSupply` must be at least 1 — zero reverts `ZeroSupply()`
(`0xc16f3a93`).

Parameters: name, symbol, mintPrice (wei), maxSupply, royaltyBps (500 = 5%), contractURI.
Deploy fee: `deployFeeFor(maxSupply)` — 1 XRP + 0.01 XRP per token — sent as value. Overpayment
is refunded; underpayment reverts `InsufficientDeployFee()`. `maxSupply` must be >= 1;
0 reverts `ZeroSupply()`.

### Deploy a Drop

```bash
cast send 0x00844CaE1ecfd0b73356f6d9cB7A7e421EC9c7A8 \
  "createDrop(string,string,uint256,uint256,uint96,uint256,string,string)" \
  "Genesis" "GEN" 500000000000000000 1000 500 5 \
  "https://arweave.net/HIDDEN_METADATA" "https://arweave.net/CONTRACT_URI" \
  --rpc-url https://rpc.xrplevm.org \
  --private-key $WALLET_PRIVATE_KEY \
  --gas-limit 3000000 \
  --value 11ether    # deployFeeFor(1000) — 1 + 0.01*1000. Quote your own supply.
```

Parameters: name, symbol, mintPrice (wei), maxSupply, royaltyBps, maxPerWallet
(must be >= 1), hiddenMetadataURI, contractURI. Reveal later with `reveal(string)`.

### Mint an NFT

```bash
# Public mint via the factory. Requires the collection to have mintOpen = true.
# Value = mintPrice (2.5% fee is deducted from what creator receives, not added on top)
# mint(collection, to, tokenURI) — use your own address as `to`
cast send 0x00844CaE1ecfd0b73356f6d9cB7A7e421EC9c7A8 \
  "mint(address,address,string)" \
  0xCOLLECTION_ADDRESS 0xYOUR_ADDRESS "https://arweave.net/TOKEN_METADATA" \
  --rpc-url https://rpc.xrplevm.org \
  --private-key $WALLET_PRIVATE_KEY \
  --gas-limit 1000000 \
  --value 1000000000000000000

# Creator self-mint — call creatorMint directly ON THE COLLECTION contract.
# Free, no mint fee, and works even when mintOpen is false. Owner only.
cast send 0xCOLLECTION_ADDRESS \
  "creatorMint(address,string)" \
  0xYOUR_ADDRESS "https://arweave.net/TOKEN_METADATA" \
  --rpc-url https://rpc.xrplevm.org \
  --private-key $WALLET_PRIVATE_KEY \
  --gas-limit 1000000
```

For a **Drop**, mint through `mintDrop(address,address,uint256)` on the factory
(drop, to, quantity) with `value = mintPrice * quantity`. The IDs you receive are random.

Two constraints on `mintDrop` worth knowing before you debug a failure:
- It requires `tx.origin == msg.sender`, so it must be sent as a **direct transaction from a
  plain wallet**. Calling it from a contract or a smart-account wallet reverts with
  `ContractCaller()`. (Plain `mint` on a Collection has no such restriction.)
- `mint` and `mintDrop` are not interchangeable — calling `mint` on a drop reverts with
  `NotACollection()`, and `mintDrop` on a collection reverts with `NotADrop()`.

### List on Marketplace

```bash
# 1. Approve marketplace
cast send 0xCOLLECTION_ADDRESS \
  "setApprovalForAll(address,bool)" \
  0xb5D697C249f219E9D47969bCcEbE7E6A65291ccA true \
  --rpc-url https://rpc.xrplevm.org \
  --private-key $WALLET_PRIVATE_KEY \
  --gas-limit 500000

# 2. List token
cast send 0xb5D697C249f219E9D47969bCcEbE7E6A65291ccA \
  "list(address,uint256,uint256)" \
  0xCOLLECTION_ADDRESS 1 10000000000000000000 \
  --rpc-url https://rpc.xrplevm.org \
  --private-key $WALLET_PRIVATE_KEY \
  --gas-limit 1000000
```

Parameters: collection address, tokenId, price in wei (10 XRP = 10e18). The new ID comes
back in the `Listed` event and is global, not per-collection. Listing a token that is
already listed retires the older listing and emits `ListingSuperseded` — see
[One listing per token](#one-listing-per-token).

### Buy a Listing

```bash
cast send 0xb5D697C249f219E9D47969bCcEbE7E6A65291ccA \
  "buy(uint256)" 1 \
  --rpc-url https://rpc.xrplevm.org \
  --private-key $WALLET_PRIVATE_KEY \
  --gas-limit 1000000 \
  --value 10000000000000000000
```

Value must cover listing price. 1% platform fee + creator royalty deducted automatically.
Overpayment is refunded. Reverts `ListingNotActive` or `ListingStale` if this is no longer
the token's current listing — check `activeListingOf` first if you cached the ID.

### Read Collection Info

Always put the **return type** in the signature — `"totalCollections()(uint256)"`, not
`"totalCollections()"`. Without it `cast` hands back raw ABI hex you then have to decode
by hand. Every command below was run against mainnet; the comment shows the real answer.

```bash
# Registry size — note this counts collections AND drops
cast call 0x00844CaE1ecfd0b73356f6d9cB7A7e421EC9c7A8 \
  "totalCollections()(uint256)" --rpc-url https://rpc.xrplevm.org
# -> 3

cast call 0x00844CaE1ecfd0b73356f6d9cB7A7e421EC9c7A8 \
  "totalDrops()(uint256)" --rpc-url https://rpc.xrplevm.org
# -> 2

# Get contract address by registry index
cast call 0x00844CaE1ecfd0b73356f6d9cB7A7e421EC9c7A8 \
  "allCollections(uint256)(address)" 0 --rpc-url https://rpc.xrplevm.org
# -> 0x41618438c680D646BeaC253fda23d312B4629565

# Which kind is it? -> "collection" or "drop"
cast call 0x00844CaE1ecfd0b73356f6d9cB7A7e421EC9c7A8 \
  "collectionType(address)(string)" 0xCOLLECTION_ADDRESS --rpc-url https://rpc.xrplevm.org

# Platform fees.
# deployFeeFor(maxSupply) is the authoritative deploy quote — pass the supply you
# intend to declare and send exactly that as msg.value. Do NOT assume a flat fee.
cast call 0x00844CaE1ecfd0b73356f6d9cB7A7e421EC9c7A8 \
  "deployFeeFor(uint256)(uint256)" 1 --rpc-url https://rpc.xrplevm.org
# -> 1010000000000000000  (1.01 XRP, a one-of-one)
cast call 0x00844CaE1ecfd0b73356f6d9cB7A7e421EC9c7A8 \
  "deployFeeFor(uint256)(uint256)" 10000 --rpc-url https://rpc.xrplevm.org
# -> 101000000000000000000  (101 XRP, a 10,000-item drop)

# The two components, if you want to compute it yourself:
cast call 0x00844CaE1ecfd0b73356f6d9cB7A7e421EC9c7A8 \
  "baseFee()(uint256)" --rpc-url https://rpc.xrplevm.org
# -> 1000000000000000000  (1 XRP flat, per collection)
cast call 0x00844CaE1ecfd0b73356f6d9cB7A7e421EC9c7A8 \
  "perTokenFee()(uint256)" --rpc-url https://rpc.xrplevm.org
# -> 10000000000000000  (0.01 XRP per declared token)

cast call 0x00844CaE1ecfd0b73356f6d9cB7A7e421EC9c7A8 \
  "mintFeeBps()(uint256)" --rpc-url https://rpc.xrplevm.org
# -> 250  (2.5%)

# Collection details (works on both DropsCollection and DropsDrop)
cast call 0xCOLLECTION_ADDRESS "name()(string)"          --rpc-url https://rpc.xrplevm.org
cast call 0xCOLLECTION_ADDRESS "symbol()(string)"        --rpc-url https://rpc.xrplevm.org
cast call 0xCOLLECTION_ADDRESS "totalMinted()(uint256)"  --rpc-url https://rpc.xrplevm.org
cast call 0xCOLLECTION_ADDRESS "maxSupply()(uint256)"    --rpc-url https://rpc.xrplevm.org
cast call 0xCOLLECTION_ADDRESS "mintPrice()(uint256)"    --rpc-url https://rpc.xrplevm.org
cast call 0xCOLLECTION_ADDRESS "mintOpen()(bool)"        --rpc-url https://rpc.xrplevm.org
cast call 0xCOLLECTION_ADDRESS "owner()(address)"        --rpc-url https://rpc.xrplevm.org
cast call 0xCOLLECTION_ADDRESS "contractURI()(string)"   --rpc-url https://rpc.xrplevm.org

# Token-level reads — the ID must actually exist (random on Drops!)
cast call 0xCOLLECTION_ADDRESS "tokenURI(uint256)(string)"  8 --rpc-url https://rpc.xrplevm.org
cast call 0xCOLLECTION_ADDRESS "ownerOf(uint256)(address)"  8 --rpc-url https://rpc.xrplevm.org

# Marketplace state — nextListingId is a global counter, not per-collection
cast call 0xb5D697C249f219E9D47969bCcEbE7E6A65291ccA \
  "nextListingId()(uint256)" --rpc-url https://rpc.xrplevm.org
cast call 0xb5D697C249f219E9D47969bCcEbE7E6A65291ccA \
  "listings(uint256)(address,address,uint256,uint256,bool)" 1 --rpc-url https://rpc.xrplevm.org
# -> seller, collection, tokenId, price, active

# The one listing allowed to execute for a token (0 = none tracked)
cast call 0xb5D697C249f219E9D47969bCcEbE7E6A65291ccA \
  "activeListingOf(address,uint256)(uint256)" 0xCOLLECTION_ADDRESS 1 \
  --rpc-url https://rpc.xrplevm.org
```

Decoding the reverts you are most likely to hit:

| Selector | Error | Means |
|----------|-------|-------|
| `0x7e273289` | `ERC721NonexistentToken(tokenId)` | That ID was never minted. On a Drop it usually means you guessed a sequential ID |
| `0x66cb03e9` | `ListingNotActive()` | Sold, cancelled, or superseded by a newer listing for the same token |
| `0x92ed4985` | `ListingStale()` | Still flagged active, but a newer listing owns the token — see [One listing per token](#one-listing-per-token) |

### Make an Offer

```bash
cast send 0x917AA02C718EFbF0621FD72Dd39E70C074b6732d \
  "makeOffer(address,uint256,uint256)" \
  0xCOLLECTION_ADDRESS 1 604800 \
  --rpc-url https://rpc.xrplevm.org \
  --private-key $WALLET_PRIVATE_KEY \
  --gas-limit 1000000 \
  --value 5000000000000000000
```

Parameters: collection, tokenId, duration in seconds (604800 = 7 days). Offer amount sent as value (escrowed).

### Create an Auction

```bash
# 1. Approve auction contract
cast send 0xCOLLECTION_ADDRESS \
  "setApprovalForAll(address,bool)" \
  0x4c73c939737Eaa3dAC015528cF967D52C9B344b7 true \
  --rpc-url https://rpc.xrplevm.org \
  --private-key $WALLET_PRIVATE_KEY \
  --gas-limit 500000

# 2. Create auction (startPrice 5 XRP, reserve 20 XRP, duration 3 days)
cast send 0x4c73c939737Eaa3dAC015528cF967D52C9B344b7 \
  "createAuction(address,uint256,uint256,uint256,uint256)" \
  0xCOLLECTION_ADDRESS 1 5000000000000000000 20000000000000000000 259200 \
  --rpc-url https://rpc.xrplevm.org \
  --private-key $WALLET_PRIVATE_KEY \
  --gas-limit 1000000
```

## REST API (`api.drops.market`)

The indexer behind drops.market. **Read endpoints need no auth, no key, and no CLI** —
plain HTTP GETs, handy when you want state the contracts don't aggregate (real Drop token
IDs, activity history, floor prices).

**Mainnet only.** The indexer has no chain column, so testnet activity never appears here.

Every response is `{ "data": …, "block": <indexed block> }` — the exception is `/health`,
which returns its status fields at the top level. Wei values are already converted to
decimal XRP numbers, so `mintPrice: 15` means 15 XRP, not 15 wei. All endpoints below were
confirmed returning 200.

| Endpoint | Returns |
|----------|---------|
| `GET /health` | Indexer + DB + Arweave status, `lastBlock`, `lag` |
| `GET /stats` | Platform totals: collections, drops, tokens, listings, offers, auctions, volume |
| `GET /stats/activity?limit=N` | Global recent activity (mints, listings, sales) |
| `GET /collections` | All visible collections with mint price, supply, creator, royalty |
| `GET /collections?include=stats` | Same list with extra aggregate stats |
| `GET /collections/:address` | One collection: symbol, revealed, floorPrice, activeListings/Offers |
| `GET /collections/:address/tokens` | **Real minted token IDs + owners** |
| `GET /collections/:address/tokens/:tokenId` | Owner plus that token's listing, offers, auction |
| `GET /collections/:address/listings` | Active listings for the collection |
| `GET /collections/:address/activity` | Collection event history |
| `GET /users/:address/tokens` | NFTs the wallet owns |
| `GET /users/:address/listings` | The wallet's active listings |
| `GET /users/:address/activity` | The wallet's transaction history |
| `GET /users/:address/created` | Collections the wallet deployed |
| `GET /users/:address/offers` | Offers the wallet made |
| `GET /users/:address/offers-received` | Offers on the wallet's tokens |
| `GET /users/:address/earnings` | Creator earnings |
| `GET /featured` | Curated collections shown on the homepage |

Examples, with real responses:

```bash
# Is the indexer caught up? lag 0 means yes.
curl -s https://api.drops.market/health | jq '{db, indexer}'

# Platform totals
curl -s https://api.drops.market/stats | jq '.data'
# -> { "totalCollections": 1, "totalDrops": 2, "totalTokens": 7, ... }

# A Drop's real (random) token IDs
curl -s https://api.drops.market/collections/0x41618438c680d646beac253fda23d312b4629565/tokens \
  | jq '[.data[].tokenId]'
# -> [8, 29, 46, 49, 133, 137, 149]

# One token, with its market state
curl -s https://api.drops.market/collections/0x41618438c680d646beac253fda23d312b4629565/tokens/8 | jq '.data'
# -> { "tokenId": 8, "owner": "0x4e2e…", "listing": null, "offers": [], "auction": null }
```

Read endpoints are rate limited to **100 requests per minute per IP**. Address paths are
case-insensitive. A collection that doesn't exist (or has been hidden) returns
`404 {"error":"Collection not found"}`.

Write endpoints — the `/upload/*` family — are signature-gated; see below.

## Arweave Storage

All NFT metadata and images are stored permanently on Arweave, **paid for by DROPS**
(the platform holds the Arweave credits). Uploads route through `api.drops.market`;
the CLI authenticates each request with a short-lived wallet signature. You never need
Arweave/Turbo credits — just XRP for on-chain fees.

**Two upload modes:**
1. **`--image`** — CLI sends the file to the DROPS API, which compresses it (WebP),
   uploads to Arweave, builds metadata, and returns the URI. Platform-funded.
2. **`--uri`** — Bring your own pre-uploaded Arweave (or any HTTPS) URL. Skips the
   upload entirely and keeps your own Arweave attribution.

**For Drops (batch):** `--images ./dir/` uploads all images, builds metadata JSONs, creates an Arweave path manifest, and uses it as baseURI. Fully automated. If any image fails to upload, the command aborts before deploying — it never ships a drop with a gapped manifest.

Set `DROPS_API_URL` to override the API endpoint (default `https://api.drops.market`).

**Metadata format** (ERC-721 standard):
```json
{
  "name": "NFT Name",
  "description": "Description",
  "image": "https://arweave.net/IMAGE_TX_ID",
  "attributes": [
    { "trait_type": "Background", "value": "Blue" }
  ]
}
```

**Gateway order:** `turbo-gateway.com` first, fallback to `arweave.net`. Content is immutable — cached forever.

### Calling the upload API directly

The CLI does this for you. Do it yourself only if you are not using the CLI.

Every `/upload/*` request carries three headers proving you control an EVM address. The
address is your quota identity — nothing else about it matters, and it does not need XRP.

| Header | Value |
|--------|-------|
| `X-Drops-Address` | Your address, **lowercased** |
| `X-Drops-Timestamp` | Current time in **milliseconds** (13 digits, `Date.now()`) |
| `X-Drops-Signature` | `personal_sign` of the message below |

The message is exactly three lines, joined with `\n`:

```
DROPS upload
address: <lowercased address>
timestamp: <same millisecond timestamp as the header>
```

Rules that bite:
- The timestamp must be **milliseconds**, not seconds. Unix seconds look like an
  expired signature and get rejected.
- The signature is valid for **10 minutes** either side of server time. Sign fresh per
  request rather than reusing one across a long batch — signing is free and instant.
- The address in the message must be lowercased and must match the header.
- EOA signatures and ERC-1271 smart-account signatures both work.

Endpoints (all `POST`):

| Endpoint | Body | Returns |
|----------|------|---------|
| `/upload/single` | multipart, field `image` | `{ txId, url }` |
| `/upload/batch` | multipart, field `images` (≤50) | `{ uploads: [{index, txId, url}], failed: [{index, error}] }` |
| `/upload/json` | `{ data: {…}, manifest?: true }` | `{ txId, url }` |
| `/upload/metadata` | `{ tokens: [{ tokenId, metadata }] }` | `{ baseURI, manifestTxId, paths }` |
| `/upload/contract-metadata` | `{ name, imageTxId, description?, thumbnailTxId? }` | `{ contractURI }` |
| `/upload/placeholder-metadata` | `{ imageTxId, name?, description? }` | `{ hiddenMetadataURI }` |
| `/upload/reserve` | `{ maxSupply, plannedBytes?, files? }` | `{ ok, limitBytes, usedBytes, remainingBytes, grantBytes, fits, … }` |

**Call `/upload/reserve` first if you are uploading a whole drop.** Upload allowance is
per-wallet, the per-request gate only sees the batch in front of it, and clients batch ~50
files — so without a reservation a large drop can be refused on batch 48 with 47 batches
already uploaded and wasted. One call checks the whole job before you spend anything, and
raises your ceiling to match a supply you can pay to deploy:

```bash
curl -X POST https://api.drops.market/upload/reserve \
  -H 'Content-Type: application/json' \
  -H "X-Drops-Address: $ADDR" -H "X-Drops-Timestamp: $TS" -H "X-Drops-Signature: $SIG" \
  -d '{"maxSupply": 10000, "files": 10000, "plannedBytes": "2100000000"}'
# -> { "ok": true, "grantBytes": "2621440000", "remainingBytes": "3145728000", "fits": true }
```

`plannedBytes` is your raw on-disk total — send it as a string. The server charges
post-compression, so `fits: true` on raw bytes is a safe yes. Refusals never upload
anything and each names its own fix:

| Code | Status | Means |
|---|---|---|
| `DEPLOY_FEE_UNAFFORDABLE` | 402 | Your wallet cannot cover `deployFeeFor(maxSupply)`. Fund it, then reserve again. |
| `RESERVATION_TOO_LARGE` | 429 | The job exceeds your ceiling even after the grant. Split the drop. Retrying is pointless. |
| `DAILY_CAP_INSUFFICIENT` | 429 | The platform's daily budget cannot fit it. Honour `retryAfterSeconds` — it resets at 00:00 UTC. |

The CLI does this for you on `create drop --images`. An API that predates the endpoint
returns 404; treat that as "no reservation available" and proceed.

Notes that save a round trip:
- Accepted image types: JPEG, PNG, WebP, GIF. Everything is re-encoded to WebP (quality 85)
  and capped at 1024px on the long edge, so quota is charged on the **compressed** size.
- `/upload/metadata` validates before spending: every token needs a non-empty
  `metadata.name` and a `metadata.image` that starts with `https://arweave.net/`. `ipfs://`
  and `ar://` are rejected — upload the art first and pass back the URL you were given.
- The `*-metadata` endpoints want a **bare TX ID**, not a URL. They build the URL for you.
- `/upload/single` and `/upload/batch` return full URLs; don't prepend a gateway yourself.

### Upload failure modes

Every failure body has a human-readable `error` string. Newer API builds add machine-readable
fields alongside it — `code` (stable, safe to branch on), `retryable` (a boolean: can the
*same* request ever succeed?), `hint`, and sometimes `retryAfterSeconds` plus a `Retry-After`
header. Branch on HTTP status, then on `code` when present.

| Status | Meaning | Retry? |
|--------|---------|--------|
| `400` | Nothing sent, unsupported image type, or invalid token metadata | No — fix the request |
| `401` | Missing/invalid signature headers, or the signature expired | Yes — re-sign with a fresh millisecond timestamp |
| `413` | File over **10 MB**, more than **50 files**, or an oversized JSON body | No — resize, or split the batch |
| `422` | Arweave rejected the bytes themselves | No — re-encode |
| `429` | **Two different causes — check the body** | See below |
| `502` | Arweave temporarily unreachable (the server already retried internally) | Yes — back off ~10s, retry once |
| `503` | Platform storage unavailable (credits exhausted or misconfigured) | No — nothing you do fixes it; report it |

The **`429` is overloaded**, and telling the two apart matters because one clears in seconds
and the other does not clear at all:

**Rate limit** — too many requests too fast. No byte counters in the body. Upload routes
allow 600 requests/minute per IP; read routes 100/minute. **Retryable** — back off a few
seconds and continue.

**Quota exceeded** — the body carries byte counters, so branch on the presence of
`limitBytes`:

```json
{
  "error": "This wallet has used its full upload allowance.",
  "code": "WALLET_QUOTA_EXCEEDED",
  "retryable": false,
  "usedBytes": "524288000",
  "limitBytes": "524288000",
  "dailyUsedBytes": "…",
  "dailyCapBytes": "5368709120"
}
```

**Neither quota error is retryable — retrying is guaranteed to fail again.** Do not put
either in a retry loop; uploads spend real money from a shared platform wallet.

- `WALLET_QUOTA_EXCEEDED` — you have spent your 500 MB base allowance. It never resets.
  Deploying a collection raises the ceiling by another 500 MB; otherwise use a different
  wallet, or supply your own Arweave URLs with `--uri`.
- `DAILY_CAP_REACHED` — the platform-wide 5 GiB/day circuit breaker tripped, so *no* wallet
  can upload. Resets at 00:00 UTC. Wait for `retryAfterSeconds`; do not poll.

One more trap: **`/upload/batch` can return HTTP 200 even when some images failed.** The
response carries a `failed` array alongside `uploads`. Always inspect it and treat a
non-empty `failed` as an error — never build a manifest or deploy a drop from a partial
result, or you ship a collection with missing art. The CLI already does this and aborts.

## XRPL EVM Tips

- **Gas estimation broken on testnet** — pass an explicit `--gas-limit` with `cast send`
  (see [Gas limits](#gas-limits) for per-call floors; deploys need at least 3,000,000).
  Mainnet estimation works fine.
- **No EIP-1559** — use `--legacy` flag for Foundry deployments.
- **`eth_getLogs` max 10,000 blocks** — chunk large range queries.
- **Testnet RPC can be flaky** — `invalid height: context did not contain latest block
  height` is a transient node error, not a bad call. Retry it.
- **XRP has 18 decimals** on EVM (not 6 like on XRPL mainchain). 1 XRP = 1e18 wei.
- **Drop token IDs are random**, Collection token IDs are sequential. Don't guess.

## Environment Variables

| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `WALLET_PRIVATE_KEY` | Write commands + uploads | — | EVM private key, 64 hex chars, `0x` optional. Signs transactions and upload auth. `PRIVATE_KEY` also accepted. |
| `DROPS_CHAIN` | No | `mainnet` | `mainnet` or `testnet`. `--mainnet`/`--testnet` override it. `CHAIN` also accepted. |
| `DROPS_API_URL` | No | `https://api.drops.market` | Base URL the CLI posts uploads to. Point at `http://localhost:3001` for a local backend. Read commands go straight to the RPC and ignore this. |
| `NO_COLOR` | No | — | Set to any value to strip ANSI colors. Also honoured: `TERM=dumb`. Colors are already off when stdout is not a terminal. |

## Links

- [drops.market](https://drops.market) — Web app
- [npm: drops-market](https://www.npmjs.com/package/drops-market) — CLI package
- [api.drops.market/health](https://api.drops.market/health) — Indexer status
- [Testnet Faucet](https://faucet.xrplevm.org) — Free testnet XRP (bot-gated)
- [XRPL EVM Docs](https://docs.xrplevm.org) — Chain docs and community links
- [Testnet Explorer](https://explorer.testnet.xrplevm.org) — View transactions
- [Mainnet Explorer](https://explorer.xrplevm.org) — View transactions
