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 Argon2d, 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.

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 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.

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

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

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

Success:

{
  "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 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.