# Faucet proof-of-work specification, version 1

Faucet proof-of-work version 1 defines the requests accepted by
`POST /v3/gas`. Every successful request pays the server's configured `PAYOUT_MIST`,
defaulting to 1 SUI (`1000000000` MIST). The protocol charges clients a configurable
amount of computation before the faucet executes a payout.

The client submits `checkpointSeq`, `recipient`, `nonce`, and `hashHex`, the
complete Argon2d output. To grind, it constructs the eight-field preimage defined
below. The server independently resolves the other five preimage fields from
protocol constants, its faucet address, and public chain state.
`GET /v3/challenge` returns those values and the current quote as a convenience.

A client repeatedly hashes the eight-field preimage from section 2 with
Argon2d. It interprets the first 8 output bytes as an integer. A value below the
threshold solves the request. Otherwise, the client increments `nonce` and
tries again.

## 1. Inputs

### Submitted fields

`POST /v3/gas` requires all four fields below. Send them as JSON strings;
using decimal strings for `u64` values avoids JSON number precision loss.

| Field | Wire format | Meaning |
| --- | --- | --- |
| `recipient` | Sui address string | Payout recipient |
| `checkpointSeq` | `u64` as a canonical decimal string | Checkpoint used to construct the proof |
| `nonce` | `u64` as a canonical decimal string | Nonce that produced the winning hash |
| `hashHex` | Exactly 64 lowercase hexadecimal characters, without `0x` | Complete 32-byte Argon2d output, defined in section 3 |

### Preimage fields

Three submitted fields, `checkpointSeq`, `recipient`, and `nonce`, are inputs
to the hash. The server validates them and resolves the other five preimage
fields independently. The table below lists all eight fields in hash order.
`hashHex` is the resulting hash, not a ninth preimage field.

| # | Field | Type | Where it comes from |
| --- | --- | --- | --- |
| 1 | `domain` | string | The literal `sui-faucet-pow/1` |
| 2 | `chainId` | string | gRPC `GetServiceInfo.chain_id`, used verbatim |
| 3 | `checkpointSeq` | u64 | Submitted checkpoint sequence, inside the freshness window |
| 4 | `checkpointDigest` | base58 | That checkpoint's `digest`, used verbatim |
| 5 | `randomBytes` | base64 | `0x8`'s `random_bytes`, read **as of that checkpoint** |
| 6 | `faucetAddress` | address | The faucet's payout address (`GET /health`) |
| 7 | `recipient` | address | Submitted payout recipient |
| 8 | `nonce` | u64 | Submitted winning nonce |

### Normalization

The server rebuilds the preimage from the three submitted preimage inputs.
The client's preimage must produce the same bytes. A submitted hash that passes
the threshold check but differs from the server's result returns `invalid_proof`.
Use these normalization rules to keep both encodings identical:

- **Addresses**: `0x` followed by 64 lowercase hex characters. Short forms must
  be zero-extended on the left (`0x2` → `0x0000…0002`).
- **Integers** (`checkpointSeq`, `nonce`): decimal, no leading
  zeros, no sign, no separators. Zero is `0`.
- **`checkpointDigest`**: base58 of the 32-byte digest, used verbatim. gRPC
  returns the digest in this form, so it goes in unchanged. The length is 43 or
  44 characters depending on the value, so do not validate against a fixed
  length.
- **`randomBytes`**: base64 of the bytes the chain returns, per RFC 4648
  section 4, with `+` and `/` as the last two alphabet characters and `=`
  padding whenever the byte length requires it. The current beacon emits
  48 bytes, which needs no padding, but a client that encodes raw bytes itself
  must not assume that. Encode gRPC `random_bytes` as base64 before building the preimage.
  When the chain had no Random object at that checkpoint, this field is the empty string
  and the line is empty.
- **`chainId`**: use gRPC `GetServiceInfo.chain_id` verbatim. It is the base58 genesis
  checkpoint digest, not a shortened hexadecimal identifier. Do not re-encode it or
  normalize its case.

The digest and the randomness are hashed together because they are unpredictable
in different ways. The digest cannot exist before its checkpoint but is assembled
by whoever builds that checkpoint, while the randomness is a threshold BLS
signature that no single party can bias. `docs/server-design.md` covers the pairing.

## 2. Preimage layout

Encode the eight fields from section 1 as UTF-8 and join them with one newline
(`U+000A`):

```
sui-faucet-pow/1
<chainId>
<checkpointSeq>
<checkpointDigest>
<randomBytes>
<faucetAddress>
<recipient>
<nonce>
```

This string is the password passed to Argon2d in section 3. There is no trailing
newline.

No field may contain a newline, which is what makes the join unambiguous without
length prefixes or escaping. Every field is confined to an alphabet that excludes
it: hex for addresses, decimal for integers, base58 for the digest, base64 for
the randomness. A checkpoint holding no randomness leaves line 5 empty and keeps
the field count at eight.

The first line is the domain separator and the PoW version marker.

## 3. Hash

Use Argon2d with these parameters:

| Parameter | Value |
| --- | --- |
| Algorithm | Argon2d |
| Version | 0x13 (19) |
| Password | The preimage of section 2, UTF-8 |
| Salt | `sui-faucet-pow-1`, ASCII, the domain of line 1 with `/` replaced by `-` |
| Memory | 8192 KiB |
| Iterations | 1 |
| Parallelism | 1 |
| Output | 32 raw bytes |

The equivalent PHC parameter string is `$argon2d$v=19$m=8192,t=1,p=1`.

Submit all 32 output bytes as `hashHex`, exactly 64 lowercase hexadecimal
characters without a `0x` prefix, whitespace, or a PHC parameter string.
The server compares every byte with its recomputed output, not just the eight
bytes used to check difficulty. Clients already have this output when they find
a winning nonce, so including it requires no additional Argon2 computation.

Version 1 uses Argon2**d**, not Argon2id or Argon2i. Several libraries expose
only the data-independent variants, so a client may need a third-party library.
`docs/server-design.md` explains the choice.

Version 1 fixes these parameters. Two servers using `sui-faucet-pow/1`
therefore produce identical hashes for identical preimages.
`GET /v3/challenge` repeats the parameters so clients can verify them. A client
must reject a challenge when any parameter differs from the implemented
version. Changing a parameter requires the version change described in
[section 7](#7-versioning).

## 4. Difficulty and threshold

Interpret the **first 8 bytes** of the 32-byte hash as a big-endian unsigned
64-bit integer, `v`. A solution is valid when `v < threshold`.

The server chooses the required difficulty, as
[section 5](#5-acceptance-rules) describes. Difficulty is
an integer, the expected number of hash attempts. The threshold follows from
it:

```
threshold = floor(2^64 / difficulty)
```

A random hash succeeds with probability
`floor(2^64 / difficulty) / 2^64`, which differs from `1/difficulty` only by
integer-rounding precision. The expected attempt count is therefore
approximately `difficulty`. This linear scale lets the server adjust work in
fine increments.

Implementations must compute the threshold with integer division. The
numerator does not fit in 64 bits, so use a 128-bit integer type or a bignum.
Floating-point division would let two implementations disagree on the final
integer.

On the wire, `difficulty` is a decimal integer string that follows the integer
normalization rules in section 1. Both sides derive the threshold from that
exact value. A server rounds fractional internal values up before quoting or
enforcing them. Rounding up preserves ordering, so the quote guarantee in
section 5 also holds for wire values.

`GET /v3/challenge` returns the quoted threshold as a decimal string. Clients
that use this endpoint do not need to calculate the threshold and can grind
directly until `v < threshold`.

### Domain of the threshold function

The function above is defined for `2 <= difficulty <= 2^48`.

The upper bound is where the threshold stops being useful. `threshold(2^48)`
is `2^16`, and beyond that the comparison loses granularity against a 64-bit
value. At a difficulty of 1 the threshold would be `2^64`, which every hash
clears and which no 64-bit integer holds. Neither bound is a policy. The
difficulty a server enforces is governed by section 5.

### Overshooting

Overshooting is always accepted. A hash that clears a harder threshold also
clears an easier one, and the server only checks `v < threshold(required)`. A
client can aim above the quoted value to keep its proof fresh while the latest
checkpoint timestamp advances.

Extra work also buys extra time. The freshness window is sized from the
difficulty a proof actually clears, so a client that grinds a margin earns a
proportionally longer deadline, up to the window cap. See
[Freshness window](#freshness-window).

## 5. Acceptance rules

The server accepts a submission only when all of these conditions hold:

1. `recipient` is a valid Sui address. `checkpointSeq` and `nonce` are canonical
   decimal integers in the unsigned 64-bit range. `hashHex` contains exactly
   64 lowercase hexadecimal characters. Missing or malformed fields return
   `invalid_request` before any chain read or Argon2 computation.
2. The claimed hash satisfies `v < threshold(required)`. Otherwise, the server
   returns `insufficient_work` before any chain read or Argon2 computation.
3. The server reads the chain identifier, latest indexed checkpoint, and submitted checkpoint
   through gRPC. Each proof checkpoint includes its sequence number, digest, consensus timestamp,
   and native randomness as of that checkpoint. Randomness comes from the most recent successful
   update affecting `0x8`, found with a descending transaction scan whose exclusive upper bound is
   `checkpointSeq + 1`. The submitted sequence must match `checkpointSeq` and cannot exceed the
   latest indexed sequence.
4. The latest checkpoint timestamp must be within the configured chain-tip age
   of the server clock. This check fails closed when the chain, gRPC endpoint,
   or server clock is stale. The submitted checkpoint's timestamp cannot be
   greater than the latest checkpoint's timestamp.
5. The submitted checkpoint is no older than `POW_MAX_WINDOW_SECONDS`, as
   measured by `latest.timestamp - submitted.timestamp`. This rejects proofs
   that cannot be fresh before the server performs the expensive hash.
6. The server rebuilds the preimage with the returned `checkpointDigest` and
   `randomBytes`. An unpruned scan that reaches checkpoint zero without a transaction affecting
   `0x8` establishes that no Random object existed; only then is `randomBytes` the empty string.
   RPC errors, unsupported update kinds, and malformed or incomplete checkpoint data return
   `not_ready`. Failed or scan-limited reads are never treated as absent randomness.
7. The tuple `(checkpointSeq, recipient, nonce)` is neither reserved nor spent.
   This tuple determines the preimage because every other field is a protocol
   constant, checkpoint value, or server identity. The server reserves it after
   the request-time chain checks and before computing the hash. A successful
   payout stores its transaction digest. A payout whose submission may have
   occurred also stores its digest, as described in section 6.
8. The hash of the server-rebuilt preimage matches all 32 claimed output bytes
   using a constant-time comparison. A mismatch returns `invalid_proof` without
   disclosing the computed hash or whether it meets difficulty. The server
   never trusts a client-supplied preimage.
9. The submitted checkpoint is no older than the `windowSeconds` earned by the
   verified hash.

Requiring the full output makes blind nonce submissions infeasible as a payout
strategy, assuming the Argon2 output is unpredictable. A fabricated low-valued
output can still pass rule 2 and incur one expensive verification before rule 8
rejects it. This requirement does not replace request admission limits.

### Required difficulty

The server enforces the integer configured by `POW_BASE_DIFFICULTY` for every
request. `GET /v3/challenge` returns that same value as `difficulty` and
`difficultyBase`. The quote therefore equals the value enforced when the proof
is submitted.

### Payout amount

The server reads `PAYOUT_MIST` at startup as a positive decimal `u64`, defaulting to
`1000000000`. Challenges advertise it as the decimal string `amountMist`; success
responses report the amount paid. Clients must read this field rather than assume
1 SUI. Payout requests cannot choose the amount.

The payout amount is not a preimage field. Configuration changes do not alter the
hash or proof version, and a proof does not bind the server to an earlier quote.
The server's startup configuration determines the payout when it accepts the proof.

### Freshness window

`GET /v3/challenge` returns `windowSeconds` alongside the static difficulty.
A proof that exceeds the required work receives more time to land.

The window uses the difficulty the proof actually clears, with the required
difficulty as its minimum and the configured maximum as its upper bound:

```
effective     = min(max(achieved, required), maxDifficulty)
windowSeconds = min(max(baseWindow * effective / base, baseWindow), maxWindow)
```

`achieved` is `floor(2^64 / v)`, and a `v` of zero counts as
`maxDifficulty`. Before the maximum window applies, the number of proofs an
attacker can stockpile is `window × hashrate / difficulty`. Substituting the
linear window formula reduces this expression to
`baseWindow × hashrate / base`, independent of difficulty.

Two consequences apply to clients:

- `windowSeconds` records the freshness allowance at challenge time. The latest
  checkpoint timestamp continues to advance after the response.
- A client that grinds past the quoted difficulty earns a longer deadline in
  proportion, until the maximum window applies.

## 6. Submitting

```http
POST /v3/gas
Content-Type: application/json

{
  "recipient": "0x1111111111111111111111111111111111111111111111111111111111111111",
  "checkpointSeq": "1713342",
  "nonce": "201",
  "hashHex": "0008faf113e0b071f5f9d5d6e1dc9321890ecb7e81059f07dedbd3dec5e902bb"
}
```

Success:

```json
{
  "status": "success",
  "digest": "…",
  "recipient": "0x…",
  "amountMist": "1000000000",
  "difficulty": "724"
}
```

`amountMist` in the response is the amount paid. `difficulty` is the value the
server enforced, which may sit below the quote the client ground to.

Failures carry a machine-readable `code`. Failures that depend on chain state
can include a fresh `challenge`, as listed below. Cheap rejection paths do not
perform a chain read to attach a challenge.

| Code | Status | Meaning | Fresh `challenge` |
| --- | --- | --- | --- |
| `invalid_request` | 400 | Malformed or out-of-range field | |
| `insufficient_work` | 400 | Claimed hash does not meet the required difficulty. Includes `requiredDifficulty`; no chain read or Argon2 computation occurs | |
| `invalid_proof` | 400 | Claimed hash differs from the recomputed output. Includes `preimageSha256` for diagnosing preimage differences | |
| `stale_checkpoint` | 409 | Checkpoint unavailable, ahead of the latest checkpoint, or outside the acceptance window | yes |
| `already_used` | 409 | Tuple is reserved or spent. Includes the transaction `digest` when submission may have occurred | yes |
| `overloaded` | 503 | Too many verifications queued, or V8 heap pressure prevents a new replay reservation. Existing live proofs remain retained | |
| `funds_unavailable` | 503 | The faucet cannot cover the payout or gas. The public message is generic service unavailability; operator logs and metrics retain the funding cause. No payout executed | |
| `not_ready` | 503 | A chain read failed, returned incomplete data, or reported a latest checkpoint too far from the server clock. Retry after chain reads recover | |
| `payout_failed` | 502 | Transaction construction failed or execution definitively failed on-chain. A `digest` means execution occurred and the proof stays spent. Inspect it, then request a new challenge before another payout | |
| `payout_status_unknown` | 502 | Submission or finality could not be confirmed. Includes the precomputed transaction `digest`. Do not request another payout until that digest is resolved | |
| `internal` | 500 | Unhandled server error | |

Unhandled errors return status 500 with code `internal`. Future protocol
versions may add codes, so clients should handle known codes explicitly and
treat unknown codes as non-recoverable failures.

`invalid_proof` responses include `preimageSha256`, the SHA-256 of the
server-rebuilt preimage as lowercase hex. Compare it against the SHA-256 of the
client's preimage: a mismatch identifies a difference in inputs or encoding.
A match means the supplied Argon2 output is incorrect for that preimage.
Neither `invalid_proof` nor `insufficient_work` includes the server's Argon2
output. An `invalid_proof` response should be investigated rather than
automatically retried with a newly ground nonce.

`already_used` responses include the transaction `digest` once submission may
have occurred. A client that lost the connection after submitting can resubmit
the same four fields and recover that digest from the 409. The client must look
up the digest before requesting another payout. An `already_used` response
without a digest means the same tuple is still being processed before its
submission status is known.

### Resubmitting a proof

A tuple is reserved when it passes rule 7 of section 5, and a submission
releases only the reservation it made itself. A submission rejected with
`already_used` leaves the existing reservation untouched.

The server releases a reservation after a failure that has no transaction
digest. A client that receives `payout_failed` without a digest, `overloaded`, or
`funds_unavailable` may resubmit the same nonce and hash once the condition clears,
provided the checkpoint remains inside the freshness window. `funds_unavailable`
clears after the faucet address balance is refilled.

A successful payout leaves the tuple spent and stores its digest. Any payout
error with a digest does the same. For a definitive on-chain failure, the digest
proves that execution occurred even though the payout effects were aborted. The
client must inspect that transaction, then request a new challenge before
grinding another proof.

The server computes the transaction digest before submission. If the submission
response is lost, it queries that digest to recover the result. A failed lookup
leaves the outcome uncertain because the transaction may already have executed.
The server returns `payout_status_unknown` with the digest and retains the proof
tuple. The same rule applies when successful execution is known but finality
confirmation fails. The client must resolve an unknown digest to a known outcome
before requesting another payout.

## 7. Versioning

The version appears in the first preimage line (`sui-faucet-pow/1`), the salt,
and the `version` field returned by `GET /v3/challenge`. Clients must reject
unrecognized versions.

The version fixes the preimage layout, hash function, hashing parameters, and
threshold rule. Changing these rules requires a new version because existing
clients would otherwise produce invalid proofs. The payout amount is deployment
configuration advertised through `amountMist`, not a versioned hashing parameter.

The number in `sui-faucet-pow/N` is the proof-of-work version. The `v3` in the
HTTP path is the faucet API version. The two are independent.

## 8. Conformance vectors

[pow-vectors.json](pow-vectors.json) provides inputs, exact preimages, thresholds, and
expected Argon2d outputs. It covers checkpoints with and without randomness.
Implementations can use these vectors to verify field joining, normalization,
and hashing without contacting a faucet.
