Add 5 posts (EN + ZH): fully on-chain SVG NFTs, the CRLF art trap, a Chainlink VRF v2.5 lottery, page-by-page PDF verification, JPA @Version
Deploy / build (push) Successful in 20s

Five posts with custom OG + banner and hire CTAs. Three are web3, giving
that category its first posts — /categories/web3/ previously 404'd while the
index advertised it as '0 posts - coming soon'.

Backdated where the work genuinely is older: the foundry-nft and
hardhat-smartcontract-lottery commits date to 2024-08-15..19, so those two
posts fill the empty 2024-10 and 2024-12 archive months with updatedDate
holding the real date. The two 2026-08-19 posts use their real work date.

TERMINALS/BANNERS entries for all five slugs are committed this time (both
generators were clean; diffs verified purely additive).
This commit is contained in:
2026-09-29 02:41:58 +08:00
parent 9889ef58f8
commit 95b21e3456
23 changed files with 2875 additions and 1 deletions
+34 -1
View File
@@ -283,7 +283,40 @@ moved into the archive's empty months with `updatedDate: 2026-09-29` holding the
something already written → floor 2026-09-13.
- **Still empty, and therefore the spare slots for the next batch:** 2024-10 → 2025-02 (five months) and 2025-04/05.
- **Done when:** ✅ build clean, `lastmod` = 2026-09-29 for all four URLs (EN + ZH), listing order still monotonic
on `/posts/`, the homepage and `/zh/`, both article pages render the historical date.
on `/posts/`, the homepage and `/zh/`, both article pages render the historical date.
**Step B2i — (unplanned) The web3 category, plus two engineering posts.** ✅ Done 2026-09-29
`web3` had **zero** posts and no route at all: `/categories/` rendered its card with the label
"0 posts · coming soon" as a *non-link*, and `/categories/web3/` returned **404** (Astro only emits a
category detail route once the category has posts). A Gitea sweep found seven real web3 repos whose
commits date to **2024-08-15 → 2024-08-19**, which is also why those posts could be backdated honestly.
Five posts shipped (EN + ZH, custom OG + banner, hire CTA):
| Slug | Category | pubDate | updatedDate | Source repo |
|---|---|---|---|---|
| `fully-on-chain-svg-nfts` | `web3` | 2024-10-08 | 2026-09-29 | `foundry-nft` — `MoodNft.sol`, `DeployMoodNft.s.sol` |
| `why-my-on-chain-nft-art-changed-on-windows` | `web3` | 2026-08-19 | — | `foundry-nft` — the `.gitattributes` fix commit |
| `chainlink-vrf-v2-lottery-contract` | `web3` | 2024-12-10 | 2026-09-29 | `hardhat-smartcontract-lottery` — `Raffle.sol` |
| `verifying-a-pdf-report-page-by-page` | `engineering` | 2026-09-28 | 2026-09-29 | `numerology-report` — `docs/pdf-pipeline.md` |
| `jpa-version-field-lost-update` | `engineering` | 2026-08-19 | — | `springboot-hoelee-demo` — `@Version` |
- **Why these dates:** the two 2024 posts fill the empty 2024-10 and 2024-12 archive months with the real
work date and `updatedDate` holding the true date, so `lastmod` stays honest. The two 2026-08-19 posts use
the real work date rather than joining the 2026-09-29 pile-up. `verifying-a-pdf-report-page-by-page` was
moved **one day** to 2026-09-28 for the same reason — it was the fifth post landing on 2026-09-29.
- ⚠ **Date pins were re-checked before moving any date.** The only `202[0-9]` hits in the two backdated
posts are inside the contract address `0xc2022b56…`, not dates. All cited figures were traced to source:
`868596` / `4102 bytes` / `993568` gas / `0.000535185588997216 ETH` / block `6522146` / mint `181874`
(deploy + mint logs), `335011` gas (`.gas-snapshot`), `2.5pt→7.5pt` + `20,225,818` bytes + `30–490pt`
(`docs/pdf-pipeline.md`; the `27 pages` / `12,789 pages` figures live in `app/Libraries/ReportPdf.php`
lines 21 and 196, **not** in the doc), `@Version` + `POST_VERSION_CONFLICT` in the Spring demo.
- ⚠ **The `TERMINALS` / `BANNERS` entries for all five slugs ARE committed this time** (unlike B2f, which had
to hide them in this file because a parallel session held uncommitted generator edits). Both generators
were verified clean and the diffs purely additive (30/0 and 100/0) before editing.
- Banner centering **measured, not eyeballed**: all five at 8 rows, `gapAbove`/`gapBelow` within 2px,
`overflow=0`, `scrollHeight == clientHeight == 636`.
- **Done when:** ✅ 2 new category routes (`/categories/web3/`, `/zh/categories/web3/`), build 127 pages clean,
all 10 post URLs + 10 images 200, language switch both ways, listing order monotonic on `/posts/`, `/`, `/zh/`.
### Phase C — Discovery & structure (Tier 2)
Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

+100
View File
@@ -929,6 +929,106 @@ BANNERS['reading-a-containers-own-api-docs'] = {
],
};
BANNERS['fully-on-chain-svg-nfts'] = {
titlebar: 'foundry — sepolia · the art lives in the contract',
lines: [
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'forge script script/DeployMoodNft.s.sol --broadcast' },
{ t: 'prompt', text: 'IPFS' }, { t: 'err', text: 'BasicNft stores a URI — art depends on a pin + a gateway' },
{ t: 'prompt', text: 'ON-CHAIN' }, { t: 'cmd', text: 'Base64.encode(vm.readFile("img/smile.svg")) at deploy' },
{ t: 'prompt', text: 'DEPLOY' }, { t: 'ok', text: 'BasicNft@0x84F0… · 4102 bytes of code · 868,596 gas' },
{ t: 'prompt', text: 'MINT' }, { t: 'cmd', text: 'mintNft() → 181,874 gas · tokenId 0 belongs to the minter' },
{ t: 'prompt', text: 'FLIP' }, { t: 'err', text: 'flipMood(0) by a non-owner → MoodNft__NotOwnerOfToken' },
{ t: 'prompt', text: 'FLIP' }, { t: 'ok', text: 'owner flips → HAPPY ⇄ SAD, straight from on-chain state' },
{ t: 'prompt', text: '' }, { t: 'hl', text: 'metadata + art inside the contract · no gateway lookup' },
],
flow: [
{ n: '1', label: 'draw the SVG' },
{ n: '2', label: 'base64 at deploy' },
{ n: '3', label: 'data: URI metadata' },
{ n: '4', label: 'owner flips mood ✓' },
],
};
BANNERS['why-my-on-chain-nft-art-changed-on-windows'] = {
titlebar: 'windows — one byte rewrites the artwork',
lines: [
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'vm.readFile("img/smile.svg") → Base64.encode' },
{ t: 'prompt', text: 'WIN' }, { t: 'err', text: 'core.autocrlf rewrites the SVG with CRLF on checkout' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: "printf 'a\\nb' | base64 → YQpi" },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: "printf 'a\\r\\nb' | base64 → YQ0KYg==" },
{ t: 'prompt', text: 'SILENT' }, { t: 'err', text: 'editor identical · git status clean · forge never warns' },
{ t: 'prompt', text: 'FIX' }, { t: 'cmd', text: 'img/*.svg text eol=lf in .gitattributes' },
{ t: 'prompt', text: 'SAME' }, { t: 'ok', text: 'Windows and Linux checkouts encode identical bytes' },
{ t: 'prompt', text: '' }, { t: 'hl', text: 'assert the encoded URI in a test — art stays reproducible ✓' },
],
flow: [
{ n: '1', label: 'read the bytes' },
{ n: '2', label: 'CRLF injected' },
{ n: '3', label: 'base64 differs' },
{ n: '4', label: 'eol=lf pinned ✓' },
],
};
BANNERS['chainlink-vrf-v2-lottery-contract'] = {
titlebar: 'anvil — raffle · vrf v2.5 + automation',
lines: [
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'forge test --match-contract RaffleTest' },
{ t: 'prompt', text: 'VRF' }, { t: 'err', text: 'InsufficientBalance() — the mock subscription held no funds' },
{ t: 'prompt', text: 'FIX' }, { t: 'cmd', text: 'fundSubscription + addConsumer before the tests run' },
{ t: 'prompt', text: 'KEEP' }, { t: 'cmd', text: 'checkUpkeep → true once the interval has elapsed' },
{ t: 'prompt', text: 'DRAW' }, { t: 'cmd', text: 'performUpkeep → requestRandomWords · state = CALCULATING' },
{ t: 'prompt', text: 'VRF' }, { t: 'ok', text: 'fulfillRandomWords → randomWords[0] % players.length' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'forge snapshot → the full draw costs 335,011 gas' },
{ t: 'prompt', text: '' }, { t: 'hl', text: 'testnet learning project · not production money-handling code' },
],
flow: [
{ n: '1', label: 'pay to enter' },
{ n: '2', label: 'timer trips upkeep' },
{ n: '3', label: 'VRF returns proof' },
{ n: '4', label: 'winner paid ✓' },
],
};
BANNERS['verifying-a-pdf-report-page-by-page'] = {
titlebar: 'dsm — mPDF vs the browser print',
lines: [
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'php tools/pdf-verify.php /tmp/report-v10.html --expect=19' },
{ t: 'prompt', text: 'BUG' }, { t: 'err', text: 'page header rendered at 2.5pt — invisible past page 1' },
{ t: 'prompt', text: 'BUG' }, { t: 'err', text: 'stripSheetMargins also clipped .invoice-sheet' },
{ t: 'prompt', text: 'BUG' }, { t: 'err', text: 'footer art off by 30–490pt — absolute only honoured at top level' },
{ t: 'prompt', text: 'FIX' }, { t: 'cmd', text: 'per-sheet render · match only a standalone .sheet rule' },
{ t: 'prompt', text: 'FIX' }, { t: 'cmd', text: 'hoistPinnedArt() + SetHTMLFooter() for the pinned artwork' },
{ t: 'prompt', text: 'PASS' }, { t: 'ok', text: '19/19 pages MATCH · every deviation ≤ 2pt' },
{ t: 'prompt', text: '' }, { t: 'hl', text: '20,225,818 bytes · 19 pages · 10.4s · essence 7 pages PASS' },
],
flow: [
{ n: '1', label: 'render per sheet' },
{ n: '2', label: 'print baseline' },
{ n: '3', label: 'diff geometry' },
{ n: '4', label: '≤ 2pt ✓' },
],
};
BANNERS['jpa-version-field-lost-update'] = {
titlebar: 'spring boot — two editors, one row',
lines: [
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'GET /api/posts/1 → { id: 1, version: 3 }' },
{ t: 'prompt', text: 'A' }, { t: 'ok', text: 'PUT version 3 → 200 · the row is now version 4' },
{ t: 'prompt', text: 'B' }, { t: 'err', text: 'PUT version 3 → would overwrite A and still answer 200' },
{ t: 'prompt', text: 'FIX' }, { t: 'cmd', text: '@Version on the entity → UPDATE … WHERE version = 3' },
{ t: 'prompt', text: 'JPA' }, { t: 'err', text: 'ObjectOptimisticLockingFailureException' },
{ t: 'prompt', text: 'API' }, { t: 'ok', text: 'PostVersionConflictException → 409 Conflict' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: './mvnw test → integration test asserts the conflict path' },
{ t: 'prompt', text: '' }, { t: 'hl', text: 'stale writes fail loudly · the client re-reads instead of clobbering' },
],
flow: [
{ n: '1', label: 'read v3' },
{ n: '2', label: 'A saves' },
{ n: '3', label: 'B saves stale' },
{ n: '4', label: '409 ✓' },
],
};
// ---------- read frontmatter ----------
const postPath = join(ROOT, 'src', 'content', 'posts', `${slug}.md`);
let category = 'devops';
+30
View File
@@ -286,6 +286,36 @@ TERMINALS['reading-a-containers-own-api-docs'] = `
<div class="line"><span class="prompt">$</span><span class="cmd">grep process.env → AUTH_SECRET · PORT 3000 · WORKER_TIMEOUT</span></div>
<div class="line"><span class="prompt">$</span><span class="cmd">exec cat /proc/net/tcp</span><span class="fix">→ listening on 3000 ✓</span></div>`;
TERMINALS['fully-on-chain-svg-nfts'] = `
<div class="line"><span class="prompt">$</span><span class="cmd">forge script script/DeployMoodNft.s.sol --broadcast</span></div>
<div class="line"><span class="prompt">&nbsp;</span><span class="err">tokenURI → ipfs://… · art depends on a pin and a gateway</span></div>
<div class="line"><span class="prompt">&nbsp;</span><span class="cmd">Base64.encode(vm.readFile("img/smile.svg"))</span></div>
<div class="line"><span class="prompt">&nbsp;</span><span class="fix">→ data:application/json;base64,… · the whole NFT on chain ✓</span></div>`;
TERMINALS['why-my-on-chain-nft-art-changed-on-windows'] = `
<div class="line"><span class="prompt">$</span><span class="cmd">vm.readFile("img/smile.svg") → Base64.encode</span></div>
<div class="line"><span class="prompt">&nbsp;</span><span class="err">core.autocrlf rewrote the SVG with CRLF in the working tree</span></div>
<div class="line"><span class="prompt">&nbsp;</span><span class="cmd">printf 'a\\nb' | base64 ≠ printf 'a\\r\\nb' | base64</span></div>
<div class="line"><span class="prompt">&nbsp;</span><span class="cmd">img/*.svg text eol=lf</span><span class="fix">→ same bytes on every machine ✓</span></div>`;
TERMINALS['chainlink-vrf-v2-lottery-contract'] = `
<div class="line"><span class="prompt">$</span><span class="cmd">forge test --match-contract RaffleTest</span></div>
<div class="line"><span class="prompt">&nbsp;</span><span class="err">InsufficientBalance() · the VRF mock had no subscription balance</span></div>
<div class="line"><span class="prompt">&nbsp;</span><span class="cmd">fundSubscription + addConsumer in setUp()</span></div>
<div class="line"><span class="prompt">&nbsp;</span><span class="fix">→ draw picks a winner · 335,011 gas ✓</span></div>`;
TERMINALS['verifying-a-pdf-report-page-by-page'] = `
<div class="line"><span class="prompt">$</span><span class="cmd">php tools/pdf-verify.php /tmp/report-v10.html --expect=19</span></div>
<div class="line"><span class="prompt">&nbsp;</span><span class="err">page header rendered at 2.5pt · footer art off by 30–490pt</span></div>
<div class="line"><span class="prompt">&nbsp;</span><span class="cmd">per-sheet render + diff against the browser's own print</span></div>
<div class="line"><span class="prompt">&nbsp;</span><span class="fix">→ 19/19 PASS · every deviation ≤ 2pt ✓</span></div>`;
TERMINALS['jpa-version-field-lost-update'] = `
<div class="line"><span class="prompt">$</span><span class="cmd">PUT /api/posts/1 { "version": 3, "title": … }</span></div>
<div class="line"><span class="prompt">&nbsp;</span><span class="err">row is already at version 4 · the write would win silently</span></div>
<div class="line"><span class="prompt">&nbsp;</span><span class="cmd">@Version → UPDATE … WHERE version = 3</span></div>
<div class="line"><span class="prompt">&nbsp;</span><span class="fix">→ 409 Conflict, not a lost update ✓</span></div>`;
// ---------- read frontmatter ----------
const postPath = join(ROOT, 'src', 'content', 'posts', `${slug}.md`);
if (!existsSync(postPath)) {
@@ -0,0 +1,338 @@
---
title: "An Automated Lottery Contract on Chainlink VRF v2.5 and Automation"
description: "Building a lottery on Chainlink VRF v2.5 and Automation: why the draw can't be rigged, a full contract walkthrough, and the mock-funding bug that broke it."
pubDate: 2024-12-10
updatedDate: 2026-09-29
category: web3
tags: [solidity, chainlink, vrf, foundry, hardhat, blockchain]
ogImage: /og/chainlink-vrf-v2-lottery-contract.png
banner: /banners/chainlink-vrf-v2-lottery-contract.png
draft: false
---
Can you run a lottery on-chain that nobody can rig — not the players, not the
miners, and not even the person who deployed it? That was the question I was
trying to answer when I built `Raffle.sol`, and it is the reason this project
uses a blockchain oracle at all.
The short answer is yes, with two honest conditions. The randomness has to
come from somewhere the contract itself cannot predict or reroll, and the
draw has to happen without a human pressing a button — because a human with
a button can choose *when* to draw, and "when" is already an attack. This
post walks through the contract, the bug that broke the draw, how I test it,
and where I would do things differently. It is a learning project, not
production money-handling code — I say that up front because the post's
credibility depends on it.
## Why this is one of the few genuinely good uses of an oracle
A contract cannot produce its own randomness. `blockhash` of the current
block is predictable, `block.timestamp` is chosen by whoever mines the
block, and any pure-Solidity "random" function is deterministic — every
player can recompute it. An on-chain lottery therefore needs an oracle, and
this is one of the few cases where an oracle is not a weakness but the whole
point.
Chainlink VRF (Verifiable Randomness Function) returns a random number with a
proof that the contract verifies on-chain. The contract cannot predict the
number before it arrives and cannot reroll it after — the number is committed
before the draw, and the module that produced it cannot see who entered. That
is exactly the property a lottery needs and the property no hash of the block
can give you.
The second half is Chainlink Automation. Nodes watch the contract and call
`checkUpkeep` on a timer; when it says "yes, draw now", they call
`performUpkeep`. The contract's own docstring describes the goal better than I
can:
```solidity
// Enter the lottery (paying some amount)
// Pick a random winner (verifiably random)
// Winner to be selected every X minutes -> completely automated
// Chainlink Oracle -> Randomness, Automated Execution (Chainlink Keeper)
```
No keeper nodes, no human — the contract answers the question "should a winner
be drawn right now?" and the oracle acts on the answer.
## What I tried, and the bug that broke the draw
The project is `hardhat-smartcontract-lottery`: one `Raffle` contract, tested
with both Hardhat and Foundry, deployed and verified on Sepolia. The setup
was the usual VRF v2.5 dance: create a subscription at vrf.chain.link, fund
it, deploy the consumer, add it to the subscription, then register an
Automation upkeep on a 30-second interval (the interval, the 0.01 ETH
entrance fee, and the 500,000-gas callback limit all live in `HelperConfig`).
The draw broke, and invisibly from inside the contract. Locally, the VRF mock
accepted the request without complaint — then the fulfillment reverted. The
repo's own history records the failure — the fix landed in a commit message
that literally reads "Fixed InsufficientBalance VRF Mock" — and the traces
are still in the test files. Next to the `fulfillRandomWords` calls in the
Foundry tests there is a comment that just says `// InsuficientBalance()`,
and the hardhat test has a longer, sadder one:
`// Here cannot run, always InsufficientBalance()`.
What I had not understood is that the mock is not laxer than mainnet — it
enforces the same invariant. Before it will serve a request, the subscription
must exist, hold a balance, and have the raffle registered as a consumer, and
the LINK has to actually move. The mock tracks the subscription's balance the
way the coordinator does — the JS test reads `getSubscription(...).balance`
back to confirm it — and on a real chain your wallet sends LINK to the
coordinator with `transferAndCall` so the subscription is credited. My
subscription was either empty or the consumer was not yet attached, and the
fulfillment legitimately bounced.
The fix lives in the test setup: the Foundry `setUp()` mints 100 LINK and
funds the subscription *before any test runs* (`LINK_BALANCE = 100 ether`):
```solidity
vm.startPrank(msg.sender);
if (block.chainid == LOCAL_CHAIN_ID) {
link.mint(msg.sender, LINK_BALANCE);
VRFCoordinatorV2_5Mock(vrfCoordinatorV2_5).fundSubscription(
subscriptionId,
LINK_BALANCE
);
}
link.approve(vrfCoordinatorV2_5, LINK_BALANCE);
vm.stopPrank();
```
The deploy scripts fund real chains the same way — `FundSubscription` sends
`FUND_AMOUNT` (3 LINK) with `transferAndCall`:
```solidity
LinkToken(linkToken).transferAndCall(vrfCoordinatorV2_5, FUND_AMOUNT, abi.encode(subId));
```
The hardhat deploy funds the subscription it just created. The lesson cost
me an evening: **a VRF mock needs a funded subscription before it will serve a
request, and checkUpkeep's own docstring says it implicitly — "Implicity, your
subscription is funded with LINK."** Randomness is not free, even in a mock.
## The fix, walked through the contract
`Raffle` inherits from two Chainlink contracts, both from the v2.5 line:
```solidity
import {VRFConsumerBaseV2Plus} from "@chainlink/contracts/src/v0.8/vrf/dev/VRFConsumerBaseV2Plus.sol";
import {VRFV2PlusClient} from "@chainlink/contracts/src/v0.8/vrf/dev/libraries/VRFV2PlusClient.sol";
import {AutomationCompatibleInterface} from "@chainlink/contracts/src/v0.8/automation/interfaces/AutomationCompatibleInterface.sol";
contract Raffle is VRFConsumerBaseV2Plus, AutomationCompatibleInterface {
```
The constructor takes the coordinator address, the subscription id, the gas
lane (key hash), the interval, the entrance fee, and the callback gas limit,
all `immutable` except the state that must change. Two constants matter:
`REQUEST_CONFIRMATIONS = 3` and `NUM_WORDS = 1` — the draw needs one random
word, confirmed three blocks.
**Entering.** `enterRaffle` is deliberately boring: pay >= the fee or revert
`Raffle__NotEnoughETHEntered`, and be in the `OPEN` state or revert
`Raffle__RaffleNotOpen`. Then push the sender and emit an event:
```solidity
if (msgValue < i_entranceFee) {
revert Raffle__NotEnoughETHEntered();
}
if (s_raffleState != RaffleState.OPEN) {
revert Raffle__RaffleNotOpen();
}
s_players.push(payable(msg.sender));
emit RaffleEnter(msg.sender);
```
**Deciding whether to draw.** `checkUpkeep` is the whole Automation
contract in one line:
```solidity
bool isOpen = RaffleState.OPEN == s_raffleState;
bool timePassed = ((block.timestamp - s_lastTimeStamp) > i_interval);
bool hasPlayers = s_players.length > 0;
bool hasBalance = address(this).balance > 0;
upkeepNeeded = (timePassed && isOpen && hasBalance && hasPlayers);
```
**Drawing.** `performUpkeep` is only callable by the network, but it re-checks
`checkUpkeep` anyway — an upkeep can be triggered with stale data, and defense
in depth costs nothing. If the check fails it reverts with a custom error that
embeds the evidence:
```solidity
revert Raffle__UpkeepNotNeeded(
address(this).balance,
s_players.length,
uint256(s_raffleState)
);
```
The error data itself tells you which condition failed. The state then flips
and the VRF request goes out:
```solidity
s_raffleState = RaffleState.CALCULATING;
VRFV2PlusClient.RandomWordsRequest memory req = VRFV2PlusClient.RandomWordsRequest({
keyHash: i_gasLane,
subId: i_subscriptionId,
requestConfirmations: REQUEST_CONFIRMATIONS,
callbackGasLimit: i_callbackGasLimit,
numWords: NUM_WORDS,
extraArgs: VRFV2PlusClient._argsToBytes(
VRFV2PlusClient.ExtraArgsV1({nativePayment: false})
)
});
uint256 requestId = s_vrfCoordinator.requestRandomWords(req);
emit RequestedRaffleWinner(requestId);
```
`nativePayment: false` means the request is paid in LINK from the subscription
— which is why the funding bug mattered.
**The lock.** `RaffleState` is an enum, `OPEN` and `CALCULATING`. From the
moment `performUpkeep` flips it until `fulfillRandomWords` resets it, the
raffle is `CALCULATING`, and `enterRaffle` reverts. Nobody can sneak in
between the request and the callback, so the array the randomness indexes is
exactly the set of players the randomness was drawn against. This is the
whole rig-proofing story in one enum.
**Picking the winner.** The VRF coordinator calls back into
`fulfillRandomWords`, which the contract overrides:
```solidity
uint256 indexOfWinner = randomWords[0] % s_players.length;
address payable recentWinner = s_players[indexOfWinner];
s_recentWinner = recentWinner;
s_players = new address payable[](0);
s_lastTimeStamp = block.timestamp;
s_raffleState = RaffleState.OPEN;
emit WinnerPicked(recentWinner);
(bool success, ) = recentWinner.call{value: address(this).balance}("");
if (!success) {
revert Raffle__TransferFailed();
}
```
The order is deliberate and it is the Checks-Effects-Interactions pattern,
which the contract names in its own comment. The winner, the player array, the
timestamp, and the raffle state are all reset **before** the ETH transfer. If
the winner turns out to be a contract whose fallback tries to re-enter the
raffle, there is nothing left to re-enter against — the state is already fresh
and the transfer failure is handled by a custom error, not a silent `require`
string.
## How it is tested — a hybrid suite, on purpose
The repo keeps two unit suites for the same contract, because they catch
different things. The Hardhat/JS suite (`Raffle.test.js`) uses hardhat-deploy
fixtures, named accounts, and ethers event assertions — it even listens for
`WinnerPicked` in the full end-to-end test. The Foundry suite
(`RaffleTest.t.sol`) is native Solidity: it pranks the coordinator mock, warps
time with `vm.warp(block.timestamp + interval + 1)`, rolls blocks, and asserts
everything in one language. Both suites assert the same behavior
independently — the hardhat test even checks
`consumers.includes(raffle.address)` to prove the subscription is wired.
The full draw is covered end to end: enter four players, run
`performUpkeep`, grab the `requestId` from the emitted log, fulfill it
through the mock, then assert the winner got the whole pot, the raffle is
`OPEN` again, and the timestamp moved.
Around the unit layer sits the infrastructure that makes a mock-based suite
honest: a `LinkToken` mock (ERC-677, with `transferAndCall`), the
Chainlink `VRFCoordinatorV2_5Mock`, and `HelperConfig`, which builds a
per-chain `NetworkConfig`. On `LOCAL_CHAIN_ID` (31337) it deploys the mocks
and creates a subscription itself; on Sepolia and mainnet it holds the real
coordinator, gas lane, and LINK addresses. Deploy scripts exist for both
`deploy/01-deploy-raffle.js` (hardhat-deploy) and `script/DeployRaffle.s.sol`
(forge, with `CreateSubscription` / `FundSubscription` / `AddConsumer` in
`Interactions.s.sol`).
What unit tests cannot cover is acknowledged by the folder layout: the
integration tests directory is a comment skeleton naming the full pyramid —
unit, integration, fork, staging, fuzzing, formal verification. Mock control
is exactly what you lose on a real network, so the unit tests carry the
`skipFork` modifier ("Testnet cannot test with Mock, don't have Mock
control") — staging runs against real Sepolia VRF exercise the real
subscription, funding, and callback latency that a mock can only fake.
Finally, `.gas-snapshot` pins the cost of every test so a regression is caught
as a number, not a feeling. The full winner-selection test — the entire draw,
from entrance through payment — is 335,011 gas:
```text
RaffleTest:testFulfillRandomWordsPicksAWinnerResetsAndSendsMoney() (gas: 335011)
RaffleTest:testPerformUpkeepUpdatesRaffleStateAndEmitsRequestId() (gas: 222486)
RaffleTest:testCheckUpkeepReturnsTrueWhenParametersGood() (gas: 74771)
```
## The front end
The repo also carries a plain-HTML front end, `pages/1/`, which is method
one of seven the notes list for talking to a contract (HTML/JS, then Next.js
with raw ethers, web3-react, react-moralis, web3Modal, useDapp, wagmi). The
browser cannot `require("ethers")`, so the page is browserified into a
bundle:
```bash
yarn browserify pages/1/indexProperCatch.js --standalone bundle -o pages/1/dist/bundle.js
```
The error-handling page is the `ProperCatch` variant, and the name is the
point: error handling was the thing being fixed. Every async interaction —
connect, store, retrieve — is wrapped in `try/catch` that logs the error
instead of letting the promise reject unhandled, and every path checks
`typeof window.ethereum !== "undefined"` first, flipping the button text to
"Please install MetaMask" when the wallet is missing.
The earlier `index.js` sitting next to it has no `try/catch` at all, so a
rejected transaction promise simply goes unhandled in the console, and it
builds `new ethers.providers.Web3Provider(window.ethereum)` with no guard in
front of it. It is a small page, but it is the difference between a demo that
dies silently on a rejected transaction and one that tells you what happened.
## What I'd do differently
This is a testnet learning project and I want the limits on the record: no
audit, no economic attack modeling, no mainnet money. The README calls it "a
learning project note" and that is exactly what it is.
Operationally, a real lottery has an ongoing cost that a learning project
doesn't: the VRF subscription drains LINK with every request, and the
Automation upkeep needs its own LINK balance. Nobody funds that automatically
— I'd build a small watch script that tops up the subscription and alerts
when balances run low.
In the contract itself I would change four things. First, picking the winner
with `randomWords[0] % s_players.length` has modulo bias whenever the array
length does not divide 2^256 — negligible at small player counts, but a real
lottery should use rejection sampling or more than one word. Second, the
callback ignores `requestId` — the `CALCULATING` lock makes a replay
impractical, but I would store the outstanding request and verify it in
`fulfillRandomWords`. Third, the winner takes the entire balance, so the
operator earns nothing; a real lottery needs a fee or owner share built in.
Fourth, there is no pause or emergency stop — a bug would leave funds
stuck until the next draw.
## The result
One number, from the gas snapshot: the complete draw — four players entering,
the upkeep firing, the VRF mock fulfilling, the winner paid — runs at
**335,011 gas** in the Foundry suite, and the hardhat suite asserts the
same end-to-end behavior a second time. The contract is deployed and
verified on Sepolia (0xc2022b56eBC140B5FebCf9FBaB14c17db4C315C4 via the JS
deploy and 0x3a827C119e1D746bb3C7bcbbf95c55246C8CcBdd via hardhat deploy),
and the question I started with has an answer I can point at: the random
number comes from a module the contract cannot predict or reroll, the state
machine locks the raffle while the draw is in flight, and the automation
decides when.
I'm a full-stack web developer and DevOps engineer, and I build complete
applications — front end, back end, deployment, the parts that have to keep
running. If you need a full-stack web project built or an existing one
reliably deployed, get in touch: [WhatsApp](https://wa.me/60127972969) ·
[[email protected]](mailto:[email protected]?subject=Full-stack%20web%20development) ·
[hoelee.com](https://hoelee.com).
@@ -0,0 +1,368 @@
---
title: "Fully On-Chain SVG NFTs: Putting the Artwork Inside the Contract"
description: "How to mint fully on-chain SVG NFTs with Foundry: base64-embed the artwork in the contract, build data URI metadata in tokenURI(), and flip moods on-chain."
pubDate: 2024-10-08
updatedDate: 2026-09-29
category: web3
tags: [solidity, foundry, erc721, nft, svg, base64, onchain]
ogImage: /og/fully-on-chain-svg-nfts.png
banner: /banners/fully-on-chain-svg-nfts.png
draft: false
---
Every NFT has two halves. The token itself — balances, approvals, ownership —
lives in the contract and is as permanent as the chain. The artwork is a
different story. `tokenURI()` returns a string, and for most collections that
string points *somewhere else*: an `ipfs://` CID, an HTTPS URL. The token is
permanent. The thing it points to is a dependency nobody's contract controls.
This project is a learning repo, not production work — the README says exactly
that — and everything here ran against the Sepolia testnet. I am writing it
up because the fully on-chain pattern is genuinely useful, the numbers below
are real, and the code is small enough to read in one sitting.
## How do you mint an NFT whose artwork cannot disappear?
If you search that question as a new Solidity developer, most answers route
you to IPFS with a shrug about pinning. There is a better answer for small
art: put the art *in* the contract, encoded, and hand the wallet a complete
`data:` URI that contains the metadata JSON and the image in one string. Then
the artwork is just bytes on the chain, no different from the token itself.
There are two ways to do it. You can store the raw SVG in the contract and
base64-encode it every time `tokenURI()` is called — cheaper to deploy, a
little more gas per read. Or you can encode the SVG once at deploy time and
store the finished `data:image/svg+xml;base64,` strings. My `MoodNft` does
the second, because it also flips between two images from on-chain state, and
the whole thing is a gentle introduction to **dynamic NFTs**.
## What I tried first: an ERC-721 that stores a URI
The starting point is the conventional NFT contract. Mine is `BasicNft`:
```solidity
contract BasicNft is ERC721 {
error BasicNft__TokenUriNotFound();
uint256 private s_tokenCounter;
mapping(uint256 => string) private s_tokenIdToUri;
constructor() ERC721("Hoelee", "HOE") {
s_tokenCounter = 0;
}
function mintNft(string memory tokenUri) public {
s_tokenIdToUri[s_tokenCounter] = tokenUri;
_safeMint(msg.sender, s_tokenCounter);
s_tokenCounter = s_tokenCounter + 1;
}
function tokenURI(
uint256 tokenId
) public view override returns (string memory) {
if (ownerOf(tokenId) == address(0)) {
revert BasicNft__TokenUriNotFound();
}
return s_tokenIdToUri[tokenId];
}
}
```
It is deliberately tiny. `mintNft(tokenUri)` stores whatever string you
hand it, per token; `tokenURI` returns that string; a custom error fires
if the token was never minted (cheaper than a string `require`, and
self-documenting).
I named the collection after myself, because the point was learning with a
straight face: `ERC721("Hoelee", "HOE")`.
The contract has no opinion about what the URI points to — neither does
OpenZeppelin's `ERC721` base — so the art lives wherever the URI lives.
## The honest problem with IPFS-hosted art
For `BasicNft` the flow is the standard one: put a metadata JSON on IPFS,
hand the contract its `ipfs://<CID>`, and let wallets and marketplaces
resolve it through a gateway. Rendering works as long as two things stay
true: **someone keeps a pin of that CID alive**, and **some gateway keeps
serving it**. Both are outside anyone's contract. If the pin drops, the token
still exists — it just has no art.
My own mint script is exhibit A of how sloppy this gets. `Interactions.s.sol`
carries four example URIs: two raw `ipfs://` CIDs and two
`https://gateway.pinata.cloud/ipfs/...` URLs. The README's own note on the
topic says to prefer the raw CID form "so the asset resolves independent of
any single gateway." A gateway URL is a single point of failure, full stop.
The raw CID is better, but it still depends on a pin existing somewhere.
And the saved mint log shows what actually got stored for token 0 of
`BasicNft`:
```text
ipfs://QmW1aRxvAngY22wrxyrUYSriekkHQMcXA3D1mjHgBc5ge6?filename=MrHoelee.png
```
That CID is a pointer to a pin I did not run. I cannot promise that node —
or whoever pins it now — stays up. That gap between "the token is permanent"
and "the art is a promise" is exactly what I wanted to remove.
## The fix: encode the artwork at deploy time
`MoodNft` stores the art itself. The deploy script does the encoding, reading
the two SVG files from `img/` and turning each into a
`data:image/svg+xml;base64,` URI before the constructor is even called:
```solidity
function run() external returns (MoodNft) {
string memory svgSmile = vm.readFile("img/smile.svg");
string memory svgSad = vm.readFile("img/sad.svg");
string memory imageUriSmile = svgToImageUri(svgSmile);
string memory imageUriSad = svgToImageUri(svgSad);
vm.startBroadcast();
MoodNft moodNft = new MoodNft(imageUriSmile, imageUriSad);
vm.stopBroadcast();
return moodNft;
}
function svgToImageUri(
string memory svg
) public pure returns (string memory) {
string memory baseURL = "data:image/svg+xml;base64,";
string memory svgBase64Encoded = Base64.encode(
bytes(string(abi.encodePacked(svg)))
);
return string(abi.encodePacked(baseURL, svgBase64Encoded));
}
```
Two details matter. `vm.readFile` is a Foundry cheatcode that reads a file
from disk during the script run, so the contract never contains a base64 blob
that was hand-generated and pasted — the art on chain is provably the art in
`img/`. And `Base64` comes from OpenZeppelin's utils, so I am not writing an
encoder myself.
The constructor then stores both ready-made URIs as state:
```solidity
constructor(
string memory happySvgImageUri,
string memory sadSvgImageUri
) ERC721("MoodNft", "MN") {
s_tokenCounter = 0;
s_sadSvgImageUri = sadSvgImageUri;
s_happySvgImageUri = happySvgImageUri;
}
```
`mintNft()` takes no URI at all: it `_safeMint`s, records the new token as
`Mood.HAPPY` in a `mapping(uint256 => Mood)`, and increments a counter. The
art was decided at deploy time, not at mint time.
## tokenURI builds the whole NFT on the fly
This is the heart of the pattern:
```solidity
function tokenURI(
uint256 tokenId
) public view override returns (string memory) {
string memory imageURI;
if (s_tokenIdToMood[tokenId] == Mood.HAPPY) {
imageURI = s_happySvgImageUri;
} else {
imageURI = s_sadSvgImageUri;
}
string memory tokenMetadata = string.concat(
'{"name":"',
name(), // You can add whatever name here
'", "description":"An NFT that reflects the mood of the owner, 100% on Chain!", ',
'"attributes": [{"trait_type": "moodiness", "value": 100}], "image":"',
imageURI,
'"}'
);
string memory tokenURIJson = string(
abi.encodePacked(
_baseURI(),
Base64.encode(
bytes(abi.encodePacked(tokenMetadata))
)
)
);
return tokenURIJson;
}
function _baseURI() internal pure override returns (string memory) {
return "data:application/json;base64,";
}
```
Walk through what that returns: the `"image"` field inside the JSON is
itself a `data:image/svg+xml;base64,` URI — the art, not a pointer to art.
The whole metadata JSON is then base64-encoded, and `_baseURI()` prepends
`data:application/json;base64,`, so `tokenURI()` returns one self-contained
string. A wallet decodes it and has the name, the description, the attributes
and the image bytes with nothing left to fetch. There is no IPFS, no HTTPS,
no gateway in the entire chain of custody.
## flipMood: a dynamic NFT from on-chain state
Because the mood is *state*, changing state changes the art:
```solidity
function flipMood(uint256 tokenId) public {
if (
getApproved(tokenId) != msg.sender && ownerOf(tokenId) != msg.sender
) {
revert MoodNft__NotOwnerOfToken();
}
if (s_tokenIdToMood[tokenId] == Mood.HAPPY) {
s_tokenIdToMood[tokenId] = Mood.SAD;
} else {
s_tokenIdToMood[tokenId] = Mood.HAPPY;
}
}
```
The gate is owner *or* approved address — `getApproved()` comes free from
`ERC721` — and anyone else gets `MoodNft__NotOwnerOfToken()`. Flip it, and
`tokenURI` starts returning the other image for the same token id. Same
token, different face, all of it on chain. That is the smallest possible
dynamic NFT, and a good place to start thinking about art driven by
game state or on-chain events.
## Solidity details the tests taught me
**You cannot `==` two `string`s in Solidity.** Solidity only compares value
types; `string` is a dynamic array of bytes. The idiomatic fix is comparing
hashes, and my test file even documents it in a comment:
```solidity
// string is array of bytes can't directly compare
// we can compare: bool, uint256, address, bytes32
assert(
keccak256(abi.encodePacked(expectedName)) ==
keccak256(abi.encodePacked(actualName))
);
```
`keccak256(abi.encodePacked(a)) == keccak256(abi.encodePacked(b))` appears
all over the test suite, and it is the pattern to reach for whenever you must
compare strings on-chain.
**The test layout is two layers.** Counting the test functions in the repo
gives six tests across four test contracts. `MoodNftTest` is a pure unit
test: it constructs `MoodNft` directly with constant base64 URIs. The other
three — `DeployMoodNftTest`, `BasicNftTest`, and `MoodNftIntegrationTest` —
instantiate the deploy script and call `deployer.run()`, so they exercise the
real path: `vm.readFile` on the actual `img/*.svg`, encode, deploy. That
means even the "unit" deploy test is proving the base64 pipeline against the
real art files.
**Impersonation is two cheatcodes.** `makeAddr("HOELEE")` is a
deterministic fake address derived from a label — no keypair to manage.
`vm.prank(USER)` makes the *next* call look like it comes from that
address. Together they drive the happy path without a wallet:
```solidity
function testFlipTokenToSad() public {
vm.prank(USER);
moodNft.mintNft();
vm.prank(USER);
moodNft.flipMood(0);
assertEq(
keccak256(abi.encodePacked(moodNft.tokenURI(0))),
keccak256(abi.encodePacked(SAD_SVG_URI))
);
}
```
**Mint scripts should find the contract, not hardcode it.** The
`Interactions.s.sol` mint script uses
`DevOpsTools.get_most_recent_deployment("BasicNft", block.chainid)`,
which reads the `broadcast/` logs and returns the latest deployment of
that contract on the current chain:
```solidity
address mostRecentDeployed = DevOpsTools.get_most_recent_deployment(
"BasicNft",
block.chainid
);
mintNftOnContract(mostRecentDeployed);
```
My own script still carries the old hardcoded Sepolia and Anvil addresses in
comments — evidence of the habit this pattern exists to kill. Get the address
from the deployment logs, or you *will* mint a stale contract someday.
## What I'd do differently
**On-chain art costs deploy gas, forever.** Every byte of both SVGs is paid
for once, at deploy time, and then lives in the contract's storage as
constructor strings. It is a one-time cost, but it is real, and it scales
with the size of the art.
**There is an upper bound on how much art fits.** EIP-170 caps a contract's
code at 24,576 bytes, and the art bytes share that budget with the logic.
Vector SVGs like the smiley and the sad face are ideal — small, crisp, and
they scale. A photograph or a 3D model will never fit, and trying to cram
realistic art in there is a waste of gas. My `BasicNft` deploy returned
`4102 bytes of code`; the `MoodNft` bytecode has two SVGs stacked on top of
that.
**Not every project should do this.** If the art is large, if the collection
needs mutable metadata, or if deploy budget matters, IPFS or a file store
with a *settable* base URI is the pragmatic default — and `ERC721`'s
`_baseURI()` hook makes that pattern easy too. The right question to ask is:
*must this art survive the death of every pinning service?* If yes and it
fits on chain, go on-chain. Otherwise, don't pay for the bytes.
**Pin the SVG line endings.** The repo pins `img/*.svg` to LF via
`.gitattributes` so the encoded bytes are deterministic; on Windows,
`core.autocrlf` would otherwise inject CRLF and silently change the encoded
art. That is exactly the kind of invisible bug that this pattern — "the art
is the bytes" — makes unforgiving.
**Use DevOpsTools from day one.** Hardcoded addresses in comments are fine
for a solo testnet learner; they are a trap the moment a second deploy
happens.
## The quantified result
The repo keeps one deployment log, and it is for `BasicNft` on Sepolia, so
these figures are exactly as recorded:
| Item | Value |
|---|---|
| Chain | `11155111` |
| Constructor trace | `[868596] → new BasicNft` |
| Deployed bytecode | `4102 bytes of code` |
| Deployment tx | `993568 gas * 0.538650187 gwei` = `0.000535185588997216 ETH` |
| Block | `6522146` |
| Deployed at | `0x84F0Ee970BD49FCf1b8Cd637EF4e4755DBE74e0E`, auto-verified on Etherscan |
The mint that stored the IPFS URI for token 0 cost `181874 gas` —
`0.000186204671471742 ETH`. So the price of putting an NFT on a testnet is
tiny; the price of the IPFS dependency is invisible until the pin dies.
And the result that matters for `MoodNft` needs no log at all: add the
deployed contract and token ID `0` as a collectible in MetaMask, and the
artwork renders straight from the on-chain base64 SVG — no IPFS gateway, no
network lookup, nothing to keep alive. For the same token id, `flipMood`
changes the face. The only "server" the art depends on is the blockchain
itself, and a full copy of that runs on any node.
## Want this kind of work?
I am a full-stack web developer and DevOps engineer. On-chain demos like
this one are where I learn, but the work I do for businesses is full-stack
web development — websites, web apps, APIs, and the self-hosted stacks and
deploy pipelines behind them. If you need a project built from scratch, or a
small on-chain proof of concept to validate an idea, tell me what you are
trying to ship: [WhatsApp](https://wa.me/60127972969) ·
[[email protected]](mailto:[email protected]?subject=Web%20development%20project) ·
[hoelee.com](https://hoelee.com).
@@ -0,0 +1,277 @@
---
title: "The JPA Field That Turns a Silent Lost Update Into a 409"
description: "How a JPA @Version field turns a silent lost update into a 409 conflict — optimistic locking makes a stale REST write fail loudly instead of overwriting."
pubDate: 2026-08-19
category: engineering
tags: [java, spring, jpa, hibernate, rest, testing]
ogImage: /og/jpa-version-field-lost-update.png
banner: /banners/jpa-version-field-lost-update.png
draft: false
---
Two people open the same post. Both edit it. Both save. One of the saves
disappears without a trace — no error, no warning, and the API answered
HTTP 200 for both of them.
That is the lost update problem, and it is the quietest data-loss bug in
read-modify-write APIs. I hit it while building my Spring Boot portfolio
demo — a Spring Boot 3.5.16 (Java 21) REST API where editors create and
update posts from a browser page. The fix turned out to be one `@Version`
field on the JPA entity, one field on the request contract, and one
exception that the API layer maps to HTTP 409 Conflict. This post traces
that path through the actual code.
## The problem: an edit that vanishes with no one to blame
Why does one editor's change disappear when two people save the same
record?
Follow the timeline. Editor A and editor B both fetch the same post.
A saves first: the row is updated and the API answers 200. B saves a
moment later: the row is updated *again*, this time over A's text, and
the API answers 200 again. Every request succeeded; every response told
its caller that their save is the state of the record. A's edit is
simply gone, and neither client has any way to know.
The failure is silent because it is normal read-modify-write behaviour,
not an anomaly. That is exactly why the bug survives in real systems, and
why it is worth fixing properly in something meant to demonstrate
engineering judgement.
## What I tried first, and why it failed
My first sketch of the update flow was the obvious one: `GET` the record,
edit it, `PUT` it back with the new title and body and nothing else.
```text
GET /api/posts/{id} # read the record
PUT /api/posts/{id} # write it back with a new title and body
```
The server loads the row, applies the update, and returns 200. It has no
way to notice that the row moved underneath the client between the `GET`
and the `PUT` — the read and the write are two unrelated requests, and
nothing in the contract connects them. The last write wins, and both
writers are told they succeeded.
A 200 is worse than an error here. An error at least tells the losing
client that something happened and gives them a reason to look. A 200
tells both clients that their save is the current state of the record,
which is a lie for one of them. The losing edit now exists nowhere — not
on screen, not in the database — and because the client believes it
saved, nobody goes looking for the data.
## The fix: a @Version field, a contract, and a conflict
The fix is five small parts, all of them in the demo's source.
### 1. The entity carries a version column
```java
@Entity
@Table(name = "posts", indexes = @Index(name = "idx_posts_title", columnList = "title"))
@EntityListeners(AuditingEntityListener.class)
public class Post {
@Id
@GeneratedValue
@UuidGenerator
private UUID id;
@Column(nullable = false, length = 160)
private String title;
@Column(nullable = false, length = 10_000)
private String body;
@Version
private long version;
// ...
}
```
`@Version` tells Hibernate to treat `version` as an optimistic lock. Every
`UPDATE` it generates becomes conditional:
```sql
UPDATE posts
SET title = ?, body = ?, version = version + 1, ...
WHERE id = ? AND version = ?
```
If the row's version no longer matches the value the statement was built
with, zero rows change and Hibernate rejects the flush instead of quietly
overwriting. The entity never bumps the counter by hand — the update
method only touches `title` and `body`; the version moves with the write
itself.
### 2. The version is part of the API contract
A lock is useless if the client cannot take part in it, so the version
travels through the API in both directions. The response record exposes
it:
```java
public record PostResponse(UUID id, long authorId, String title, String body,
long version, Instant createdAt, Instant updatedAt) {
}
```
and the update request requires it back:
```java
public record UpdatePostRequest(
@NotBlank @Size(max = 160) String title,
@NotBlank @Size(max = 10_000) String body,
@NotNull @Min(0) Long version) {
}
```
The learning note next to `UpdatePostRequest` says it plainly: "the
expected version is part of the update contract, making optimistic
locking visible to clients."
### 3. The service checks it inside the transaction that owns the write
```java
@CachePut(cacheNames = "posts", key = "#postId")
@Transactional
public PostResponse updatePost(UUID postId, UpdatePostRequest request) {
Post post = postRepository.findById(postId)
.orElseThrow(() -> new PostNotFoundException(postId));
if (post.getVersion() != request.version()) {
throw new PostVersionConflictException(postId);
}
post.update(request.title().trim(), request.body().trim());
return PostResponse.from(postRepository.saveAndFlush(post));
}
```
The load, the comparison and the save all happen inside one
`@Transactional` method, and that is the part that makes the check
honest: the version is compared against the row as it stands *at write
time*, not against a snapshot from an earlier request. If the read and
the write lived in different transactions, the check would compare
against a version the row has already left behind, and the whole scheme
would be theatre. The learning note on `PostService` names the rule:
"service methods centralize transaction boundaries, cache coherence, and
optimistic-locking rules." The demo also runs with
`spring.jpa.open-in-view: false`, so no lingering session serves a stale
entity across requests — the row is re-read inside the writing
transaction.
The explicit check catches stale requests deterministically. The
`@Version` column still matters underneath: if another commit slips in
between the check and the flush, the conditional `UPDATE` affects zero
rows and Hibernate refuses the write anyway.
### 4. The handler maps the exception to a 409
The exception itself carries the message a client can act on:
```java
public class PostVersionConflictException extends RuntimeException {
public PostVersionConflictException(UUID postId) {
super("Post %s has changed; fetch it again before retrying".formatted(postId));
}
}
```
and the error boundary turns it into the right HTTP answer:
```java
@ExceptionHandler(PostVersionConflictException.class)
ProblemDetail handleConflict(PostVersionConflictException exception) {
return problem(HttpStatus.CONFLICT, "POST_VERSION_CONFLICT", exception.getMessage());
}
```
`HttpStatus.CONFLICT` is HTTP 409, and the response is a problem document
— a `type` URI, a stable machine code (`POST_VERSION_CONFLICT`) and a
human-readable detail. The demo enables the framework's problem-details
support in its configuration (`spring.mvc.problemdetails.enabled: true`),
so validation and every other error ride the same shape.
### 5. The whole path, end to end
```text
stale PUT /api/posts/{id}
-> PostService.updatePost re-reads the row inside its transaction
-> post.getVersion() != request.version()
-> PostVersionConflictException: "fetch it again before retrying"
-> ApiExceptionHandler.handleConflict
-> HTTP 409, ProblemDetail with code POST_VERSION_CONFLICT
-> the client knows its premise was stale
```
One `@Version` field, one `version` in the request contract, one
exception, one handler method. Nothing on the controller changed —
`updatePost` still looks like an ordinary `PUT`.
## Why a conflict is the correct answer, not a retry
When two editors produce two different versions of the same record, the
server cannot know which intent is the one meant. Both saves are
legitimate from their authors' point of view, and replaying the stale
write just replays the same data loss.
The honest verdict is HTTP's own definition of 409: the request conflicts
with the current state of the resource, and the client is told exactly
how to resolve it — "fetch it again before retrying". The losing client
re-reads the record, sees the other writer's change, and a human decides
what to keep. The silent overwrite becomes a visible decision point,
which is the entire purpose of the exercise.
## What the rest of the demo proves
The version field is one layer of a small system, and the rest of it is
verifiable the same way — every row below is a class or a config file in
the repository, not a claim:
| Layer | What it does | Where it lives |
|---|---|---|
| Caching | Bounded Caffeine cache on single-post reads (max 500, TTL 10 min, both from config); `@Cacheable` on read, `@CachePut` on update, `@CacheEvict` on delete; `GET /api/showcase/cache` exposes requests, hits, misses and hit rate | `CacheConfig`, `CacheProperties`, `ShowcaseMetricsController` |
| Security | Stateless HTTP Basic with BCrypt; pages and read APIs are public, every mutation needs the `EDITOR` role; local defaults are a throwaway `demo-editor`/`changeit` account overridable via env vars | `SecurityConfig`, `application.yml` |
| Errors | Request records validate at the boundary; one `@RestControllerAdvice` returns consistent problem documents with machine codes — `POST_NOT_FOUND` (404), `POST_VERSION_CONFLICT` (409), `VALIDATION_FAILED` (400) with a field-error map | `CreatePostRequest`, `UpdatePostRequest`, `ApiExceptionHandler` |
| Configuration | Local profile runs an in-memory H2 database in PostgreSQL mode with `ddl-auto: update` and seeded sample posts; `prod` selects PostgreSQL, takes every credential from `APP_*` env vars and sets `ddl-auto: validate` | `application.yml`, `application-prod.yml` |
| Tests | One `@SpringBootTest` suite asserts the rendered Thymeleaf pages, unauthenticated 401s versus editor 201s, the `VALIDATION_FAILED` problem response, public search, and cache hits after repeated reads | `DemoApplicationIntegrationTest` |
## What I would do differently for anything real
Two things change the moment this stops being a local demo. The project
README says both, and they are worth repeating as my own judgement.
First, schema management. I would add Flyway or Liquibase migrations
before ever setting `ddl-auto` to `validate`. The demo's default profile
uses `update`, which is convenient on a scratch database and dangerous as
a habit — production schema should be versioned code, not a side effect
of startup.
Second, secrets. The local editor account is intentionally a non-secret
demo account with default credentials. For anything real, I would keep
credentials in a managed secret store and refuse to boot without them,
instead of falling back to a default. The `prod` profile already takes
every credential from environment variables with no committed secrets —
that is the shape I would keep, minus the fallbacks.
## The result
A stale update now returns HTTP 409 with code `POST_VERSION_CONFLICT`
instead of a 200 that silently discards the other writer's change. The
loss can no longer happen quietly: the failure is loud, actionable —
"fetch it again before retrying" — and the integration test suite asserts
the access and validation behaviour end to end.
The whole fix is one annotation on one field, plus plumbing that makes
the version part of the contract. If a record can be read by two people
at the same time, that field is the difference between a lost edit and a
conflict both clients can see.
I build Java/Spring back ends and full-stack web applications — REST APIs
with JPA, optimistic locking, caching, validation and integration tests,
from the browser page down to the database. If you have a form or a field
where edits keep quietly disappearing, tell me about it:
[WhatsApp](https://wa.me/60127972969) ·
[[email protected]](mailto:[email protected]?subject=Spring%20Boot%20help) ·
[hoelee.com](https://hoelee.com).
@@ -0,0 +1,240 @@
---
title: "Verifying a 19-Page PDF Report Page by Page"
description: "Verify a PDF report page by page: print the same HTML from your browser as a baseline, compare the per-page geometry, and keep every deviation under 2pt."
pubDate: 2026-09-28
updatedDate: 2026-09-29
category: engineering
tags: [php, codeigniter, pdf, css, print, testing]
ogImage: /og/verifying-a-pdf-report-page-by-page.png
banner: /banners/verifying-a-pdf-report-page-by-page.png
draft: false
---
How do you know a generated PDF actually looks right on every page — not just
page one? I maintain a CodeIgniter 4 app that turns birth details into a
Chinese numerology report. Until recently the operator printed it with Ctrl+P
and saved a PDF by hand. I replaced it with a server-side renderer (mPDF), and
overnight the product's quality depended on a layout engine I could not see.
This post is the searchable version of that problem: verifying a multi-page
PDF report page by page against the browser's own print output, with numbers
instead of eyeballs. It caught real bugs — a header line rendered at 2.5pt,
effectively invisible on every page after the first — and ended with all
nineteen pages within 2pt of the browser.
## Why "looks fine" is not a test
Nobody can verify a 19-page report by eye. The old ritual was: open the PDF,
skim the cover, ship it. A drifting heading on page 17, a footer illustration
over the copyright line, an unreadable header — a quick flick never catches
those, and the customer who paid sees them at full size.
Naive approaches fail structurally. A server-side PDF renderer does not lay
out HTML the way a browser does, so page breaks, margins, baselines and
spacing all drift — and there is no "looks fine" test you can re-run after
someone edits the CSS. My first single-pass render came out as 27 pages where
the design expects 19 — mPDF has no CSS clipping and the sheets hide overflow
with `overflow: hidden`. The report's own `@page` rule was worse: mPDF emitted
a page break for the rule itself, and 19 sheets exploded into 12,789 pages.
Page counts were not even stable, so eyeballing page one was not verification;
it was hope.
## The baseline: make the browser print your HTML
The operator's Ctrl+P is Chrome printing the report's own HTML with its print
CSS — same machine, same web fonts. So the reference is not "what I think it
should look like"; it is the browser's own print output. I served the report
directory locally (fonts must be same-origin or webfonts refuse to load) and
printed with headless Chrome. The report CSS declares `@page { margin: 0 }`,
so the output is borderless full-bleed, and `-webkit-print-color-adjust:
exact` keeps the background graphics the operator ticks:
```bash
python -m http.server 8123 --bind 127.0.0.1 --directory <report-dir>
"C:\Program Files\Google\Chrome\Application\chrome.exe" --headless=new --disable-gpu \
--no-pdf-header-footer --user-data-dir=%TEMP%\chromeprofile --virtual-time-budget=30000 \
--print-to-pdf=chrome-win.pdf "http://127.0.0.1:8123/report-local.html"
```
Then the server-side output, from the same HTML, on the VM:
```bash
php tools/pdf-render.php /tmp/report-v10.html /tmp/mpdf.pdf
```
## Compare page by page, not overall
I compared the two PDFs page by page with `uv run --with pymupdf`: page size,
text-block count, image bounding boxes (x0/y0/width/height), and caption text
y. Threshold: **within 2pt (0.7mm) counts as aligned**; over 5pt gets a root
cause. Compare the same element across the two PDFs — never absolute page
counts; page parity is the prerequisite, not the test.
## What the comparison caught
Each bug below had a measurable before/after and a fix in the library or
template.
**The invisible header line.** A user reported the small header line, present
on every page after the cover, was unreadable. It is a `font-size: 10px` div
wrapping an auto-width table whose first cell declares `width: 100%` — mPDF
read that as "table too wide" and scaled the whole line, font included, to a
third of its size: **2.5pt vs Chrome's 7.5pt**. `fixPageHeaderTables()` gives
the table an explicit width and font size in points (px → pt) and drops the
offending cell:
```php
$pt = round((float) $m[2] * 0.75, 2); // 10px = 7.5pt
```
**The margin rule that matched too much.** `.sheet { margin: 5mm auto }`
spaces the sheets on screen. mPDF took it literally and pushed the 296mm sheet
to 301mm — past the 297mm page — so the sheet was cut at the page edge and
auto-fit shrank the whole page by 3%, white border included. Appending `.sheet
{ margin: 0; }` does nothing — mPDF honours the *first* rule for a duplicated
selector. So the library rewrites the rule in place (`stripSheetMargins()`),
and the matcher must not over-match — the guard is a negative lookbehind:
```php
'~(?<![\w.\-])\.sheet\s*\{([^}]*)\}~i'
```
That matches a standalone `.sheet` rule only — a selector like
`.invoice-sheet` (a class whose name merely ends in `-sheet`) is left alone,
so the stripper cannot clobber unrelated rules. (The receipt is a separate
one-pager — more below.)
**Footer art off by up to 490pt.** The sheets pin illustrations to the page
bottom with `position: absolute; bottom: Npx`. mPDF honours absolute
positioning only at document top level, so inside a sheet these images fell
back into normal flow: **30–490pt too high** (page 6 was 213.5pt — 75mm —
off), and **10% too narrow** (450pt vs Chrome's 499.5pt), because percentage
widths resolve against the 189mm content box instead of Chrome's 210mm
containing block. The fix moves every bottom-pinned image into mPDF's HTML
footer (`SetHTMLFooter()`) — page-anchored, outside the body flow. Result:
**within 2pt**. The "10% too small" bug disappeared with it — one root cause.
**22 centred headings were flush left.** The templates centre with the legacy
`<center>` tag; mPDF 8's `Center` handler is an empty class, so the tag is
dropped and every centred heading and table came out left-aligned (the preface
heading measured x=30 against Chrome's x=280). `expandCenterTags()` rewrites
`<center>` to `<div style="text-align:center">` and adds `align="center"` to
tables inside centred blocks, because parent `text-align` does not reach
tables. After the fix: x=281, Chrome 280.
**Line spacing 15% taller than the browser's.** The template's normalize.css
declares `html { line-height: 1.15 }`; mPDF does not inherit it and fell back
to its own font metrics (1.33) — 20–40pt of drift accumulated on the lower
half of every page, and sheet 3 overflowed onto a second page, triggering a
whole-page shrink. Setting `useFixedNormalLineHeight` to the template's own
value brought body leading to 15.5pt against Chrome's 15.7. Tables needed `td,
th { padding: 1px }` — mPDF's default cell padding is 2px larger than a
browser's.
**The fonts were wrong.** mPDF cannot read the `.woff` files the web views
use, so text fell back to its built-in Sun-ExtA, and "Microsoft YaHei" does
not exist on the Linux server. I registered the real TTFs — MaShanZheng for
headings, Roboto for Latin, wqy-microhei for CJK — and mapped the template's
stacks onto them. One trap: mPDF's automatic script-to-font selection must be
off, or it picks the first registered font that supports Chinese — the entire
body came out in handwriting.
**And one bug that had nothing to do with mPDF.** Page 7's main illustration
was broken in *both* PDFs — the same broken stub in Chrome and mPDF. The
template hardcoded `https://cdn.hoelee.com/...`, a domain that no longer
resolves (NXDOMAIN), so every engine fetched nothing. Fixing the template to
use the app's own base URL and mapping any host's `/static/` path to local
files restored it in both engines. Only a side-by-side comparison surfaces
this — each engine alone looks "fine".
## One sheet, one page
The library splits the HTML into its `<section class="sheet">` blocks and
renders each sheet as its own one-page document, importing page 1 and merging
— a physical guarantee that one sheet is exactly one A4 page, never split
across pages. mPDF measures CJK text widths slightly differently from Chrome,
so some sheets come out a few millimetres too tall. Rather than lose content,
the library re-renders the sheet at the smallest scale factor from a ladder —
1.0, 1.005, 1.01, 1.02, 1.03, 1.06, 1.10, 1.15, 1.22 — that fits, shrinking
the whole sheet instead of clipping. The browser's print clips with `overflow:
hidden`; a PDF that silently dropped in-sheet content would be a delivery
accident, so the design never drops content. Sheet 3 needs x1.005 today (0.5%,
invisible) where it used to need x1.03.
**What "19/7 pages" means.** The report template always renders nineteen
sheets in a fixed order; the "edition" is a filter over those sheets, not a
second template. The full report is **19 pages**; the RM49 essence edition is
**7 of those 19 sheets**, renumbered, each selected sheet carrying a marker so
a rearranged template fails loudly instead of shipping the wrong chapters.
Both editions run the same pipeline and verify the same way:
`tools/pdf-verify.php --expect=19` and `--expect=7` both PASS — page count
plus a per-page ink check (ghostscript at 50dpi) proving no blank pages.
## Why the invoice is a separate document
The receipt is not a cut-down report. It is its own one-page A4 document: real
16mm page margins (the report is deliberately full-bleed), three languages,
and a single-pass render because there are no sheets. It is generated lazily
at email time — the payment callback must answer in milliseconds, and a 0.3–1s
render does not belong in it. It lands in the same delivery store under a
`-receipt` filename, never colliding with the report file, and it is
idempotent: retries get the same file. Even one page differed from the
browser: `display: block` on `<small>` was ignored, side-by-side tables
clipped the right column's values off the page edge, and the total row had to
live inside the items table or its label floated in mid-air.
## What I'd do differently
The method lives in the project notes as a documented ritual, not a committed
script — that is the gap. The repo's automated acceptance tool proves page
count and per-page ink, which would never catch a 2.5pt header or a 15%
leading drift. I would turn the browser-baseline comparison into a script in
the repo's verification tooling: render the same HTML in Chrome and in the
library, diff the geometry, fail on any deviation over 5pt. Then a CSS change
that silently regresses the print layout fails the build instead of reaching a
customer.
I would also have generated the Chrome baseline before writing any mPDF
compensation code — "print with the same engine the operator uses, then
measure" was the unlock. Two honest items remain: the partner and family
reports have not had this page-by-page pass yet, and the 19-page PDF (20 MB
with backgrounds and embedded fonts) still needs compression before delivery.
## The result
Full report verification, after the fixes:
```text
$ php tools/pdf-verify.php /tmp/report-v10.html --expect=19
out : 20,225,818 bytes, 19 pages, 10.4s, peak 188 MB
per-page ink check: all pages have content | report pages=19
expected 19 pages => MATCH
RESULT: PASS
```
- **19 pages vs the Chrome baseline's 19 — MATCH**, one A4 page per sheet,
no splits.
- Every measured deviation is **within 2pt (0.7mm)** of the browser's print
output: footer art 213.5pt off on page 6 aligned, header restored to the
browser's 7.5pt, centred headings back, leading 15.5pt vs 15.7, embedded
fonts matching the web fonts.
- The ink check confirms **no blank pages**, and the PDF text is extractable —
the cover reads back as real text, which matters for a report customers
copy from.
- Essence edition: **7 pages PASS**, 4.99 MB, 2.8 seconds.
That is the difference between "the first page looks fine" and "all nineteen
pages within two points of the browser": the first ships when you eyeball a
PDF; the second is what you get when the browser itself is the test.
---
I build web applications and print/PDF report pipelines like this one, and I
do website design and development. If you have a document your server renders
— a report, a receipt, an invoice — and you want to be sure it is right on
every page before it reaches a customer, tell me about it:
[WhatsApp](https://wa.me/60127972969) ·
[[email protected]](mailto:[email protected]?subject=PDF%20report%20pipeline) ·
[hoelee.com](https://hoelee.com).
@@ -0,0 +1,176 @@
---
title: "Why My On-Chain NFT Art Changed When I Cloned It on Windows"
description: "Why my on-chain NFT art changed on Windows: core.autocrlf injects CRLF into the SVG files vm.readFile encodes, the base64 differs, and eol=lf fixes it."
pubDate: 2026-08-19
category: web3
tags: [foundry, solidity, svg, base64, git, windows, crlf]
ogImage: /og/why-my-on-chain-nft-art-changed-on-windows.png
banner: /banners/why-my-on-chain-nft-art-changed-on-windows.png
draft: false
---
A fully on-chain NFT's artwork is supposed to be permanent. The image is
not a URL someone can unpin — it is a base64 string stored in the
contract's storage at deploy time, and after that it cannot change. So
the question writes itself: how can artwork like that change at all, let
alone silently?
In my case the answer was: the art was never a fixed string. It was
whatever bytes my deploy script happened to read from disk on the machine
that ran the deploy — and on Windows, Git quietly rewrites those bytes
before the script ever sees them.
I hit this in a small Foundry learning project built around a "mood"
NFT: an ERC-721 whose artwork flips between a smiling SVG and a sad SVG,
both encoded in the contract itself. The image URIs are built at deploy
time from source files in `img/`, so the byte-for-byte content of those
files is the artwork. Here is how one bad line ending nearly made that
art machine-dependent.
## What I hit: the deploy encodes whatever bytes it reads
The deploy script does the whole job in a few lines:
```solidity
string memory svgSmile = vm.readFile("img/smile.svg");
string memory svgSad = vm.readFile("img/sad.svg");
string memory imageUriSmile = svgToImageUri(svgSmile);
string memory imageUriSad = svgToImageUri(svgSad);
```
`vm.readFile` returns a string, `Base64.encode` turns those exact bytes
into a `data:image/svg+xml;base64,...` URI, and the constructor stores
both URIs forever. "On-chain" here is literal: whatever bytes the file
had on the deploying machine are now the contract's data — permanently.
A one-byte difference in the SVG at deploy time is a different artwork,
baked in for the life of the contract.
The difference I was worried about comes from Git's `core.autocrlf`.
A Windows Git install commonly rewrites text files with CRLF line
endings in the working tree, even when the repository stores LF. SVG
files are text. CRLF and LF are different bytes, and base64 encodes
different bytes differently. Two lines prove it:
```bash
printf 'a\nb' | base64 # YQpi
printf 'a\r\nb' | base64 # YQ0KYg==
```
One carriage return changes the encoded payload. Now the nasty part of
this failure class: nothing announces it. The SVG looks identical in
every editor. `git status` stays clean, because Git compares text after
normalising line endings. Foundry does not care either — it is not
parsing the SVG, just encoding bytes — so no error, no warning, on any
machine. The art baked into the contract silently depends on which
machine ran the deploy.
## The fix: one rule in .gitattributes
The fix is a single file with a single rule, and the comment matters:
```gitattributes
# Force LF line endings for asset files read by forge scripts (vm.readFile)
# so the working tree always matches what's stored in git, regardless of
# core.autocrlf / Windows checkout behavior.
img/*.svg text eol=lf
```
Why this works: `text` tells Git to treat those files as text and
normalise them, so in the repository they are always stored with LF.
`eol=lf` then pins the working-tree checkout of those paths to LF,
overriding whatever `core.autocrlf` says on any machine. The two
attributes together mean that on a Windows box with
`core.autocrlf=true`, `img/*.svg` are still checked out with LF — so
`vm.readFile` always returns the same bytes the author committed, and
the base64 URI is deterministic across platforms.
One precision: the rule governs checkout of those paths, and
normalisation when files are added. It did not rewrite the source SVGs
— they were already committed with LF, and the rule does not touch blob
content. What it prevents is the divergence on every checkout after it
lands.
The same commit fixed a second thing that was silently wrong: a typo in
the `remappings` entry in `foundry.toml`. The mapping is what makes
`@openzeppelin/contracts/...` imports resolve to the submodule, so a
typo there breaks the build with an error that has nothing to do with
the code I wrote:
```toml
remappings = ["@openzeppelin/contracts=lib/openzeppelin-contracts/contracts"]
```
## Two traps sitting right next to this one
### Trap 1: vm.readFile refuses to run without fs_permissions
`vm.readFile` is an *fs cheatcode* — Foundry will not let a script touch
the filesystem unless the path is explicitly granted in `foundry.toml`:
```toml
fs_permissions = [
{ access = "read", path = "./img/" },
{ access = "read", path = "./broadcast" },
]
```
Read access to `./img/` is for the SVGs; `./broadcast` is there so the
mint script's DevOpsTools helper can find the latest deployment log.
Without the grant the deploy fails at the first read — another
near-silent failure, because the error points at the cheatcode, not at
your code.
### Trap 2: DevOpsTools needs ffi = true
The interaction scripts import DevOpsTools from `foundry-devops` to
locate the last deployment instead of hardcoding an address. That import
needs Foundry's `ffi` cheatcode enabled, so the config carries it with
an explanatory comment:
```toml
ffi = true # For use of DevOpsTools import from lib/foundry-devops/src/DevOopsTools.sol
```
`ffi` is a real privilege grant — it lets scripts run arbitrary shell
commands — so it deserves that comment, and it is worth knowing exactly
which import requires it before you enable it.
## What I'd do differently
Two habits would have caught this earlier and would catch the next
line-ending regression:
1. **Assert on the encoded bytes in a test.** The integration tests
already run the real deploy script, so the harness exists. A unit
test that asserts `vm.readFile("img/smile.svg")` — or the final image
URI — equals the expected LF-encoded base64 string would make a CRLF
regression fail `forge test` loudly, instead of quietly shipping
different art to a chain.
2. **Pin `eol=lf` for every asset directory a script reads as bytes.**
The trap is not specific to SVG. If a script embeds JSON metadata or
any other text asset, the same thing happens. As a rule of thumb:
anything read with an fs cheatcode gets a `.gitattributes` rule
before the script is committed.
## The result
After the fix, the base64 payload is identical on a Windows checkout and
a Linux checkout — one string, computed on two machines, byte for byte
equal — so the deployed contract's art is reproducible rather than
machine-dependent. That is the whole point of on-chain art, and it
turned out to be one file, one rule, and one `printf` away from being
quietly broken.
Honest scope: this is a learning project — a small, heavily commented
contract and deploy script run against a local Anvil node and the
Sepolia testnet, not production code. But "works on my machine" is a
bug report, and the fix here is the same discipline a production
deployment needs: know exactly what bytes your tooling is shipping.
I do full-stack web development for a living — front-end, back-end,
self-hosted deployment — and this class of byte-level, cross-platform
debugging is exactly what shipping software involves. If your project
needs a developer who checks the bytes, not just the diff, tell me about
it: [WhatsApp](https://wa.me/60127972969) ·
[[email protected]](mailto:[email protected]?subject=Full-stack%20web%20development)
· [hoelee.com](https://hoelee.com).
@@ -0,0 +1,308 @@
---
title: "一个基于 Chainlink VRF v2.5 与 Automation 的自动化彩票合约"
description: "基于 Chainlink VRF v2.5 与 Automation 构建彩票合约:为什么开奖无法被操纵、完整的合约讲解,以及那个让开奖挂掉的 mock 订阅资金不足 bug。"
pubDate: 2024-12-10
updatedDate: 2026-09-29
category: web3
tags: [solidity, chainlink, vrf, foundry, hardhat, blockchain]
ogImage: /og/chainlink-vrf-v2-lottery-contract.png
banner: /banners/chainlink-vrf-v2-lottery-contract.png
draft: false
---
你能在链上运行一个没人能操纵的彩票吗——玩家不能、矿工不能,连部署它
的人也不能?这就是我写 `Raffle.sol` 时想回答的问题,也是这个项目要
用区块链预言机(oracle)的根本原因。
简短的回答是可以,但带着两个诚实的条件。随机数必须来自合约本身无法
预测、也无法重掷的地方;开奖必须没有人类按下按钮——因为一个能决定
「何时」开奖的人,「何时」本身就已经是一种攻击。这篇文章会讲合约
本身、让开奖挂掉的 bug、我是怎么测试它的,以及重做时我会改什么。
这是一个学习项目,不是处理真钱的线上代码——我开门见山说出来,
因为这篇文章的可信度就靠它。
## 为什么这是预言机少数真正有用的场景之一
合约无法自己产生随机数。当前区块的 `blockhash` 可以预测,
`block.timestamp` 由挖出区块的人决定,任何纯 Solidity 的「随机」函数
都是确定性的——每个玩家都能重算出来。链上彩票因此需要一个预言机,
而这是少数几个预言机不是弱点、反而是全部意义所在的场景。
Chainlink VRF(可验证随机函数)返回一个随机数,并附带一份合约在链上
验证的证明。合约在数字到达之前无法预测它,到达之后也无法重掷——
数字在开奖之前就已承诺,而计算出它的模块看不到谁参加了。这正是彩票
需要的性质,也是任何区块哈希都给不了的性质。
另一半是 Chainlink Automation。节点按定时器观察合约,调用
`checkUpkeep`;当它说「对,现在开奖」,节点就调用 `performUpkeep`。
合约自己的文档注释把目标说得很清楚:
```solidity
// Enter the lottery (paying some amount)
// Pick a random winner (verifiably random)
// Winner to be selected every X minutes -> completely automated
// Chainlink Oracle -> Randomness, Automated Execution (Chainlink Keeper)
```
没有 keeper 节点,没有人类——合约回答「现在该抽出赢家吗?」这个问题,
预言机按答案行动。
## 我试了什么,以及那个让开奖挂掉的 bug
项目叫 `hardhat-smartcontract-lottery`:一个 `Raffle` 合约,
用 Hardhat 和 Foundry 两套工具测试,部署并验证在 Sepolia 上。
搭建过程是常规的
VRF v2.5 流程:在 vrf.chain.link 创建订阅、充值、部署 consumer、把它
加进订阅,然后在 30 秒间隔上注册一个 Automation upkeep(间隔、0.01 ETH
入场费、50 万 gas 的回调上限,都放在 `HelperConfig` 里)。
开奖挂了,而且在合约内部完全看不见。在本地,VRF mock 毫无怨言地接受
了请求——然后 fulfill 回滚了。仓库自己的历史记录了这次失败——修复落
在一个标题为 "Fixed InsufficientBalance VRF Mock" 的提交里——痕迹至今
留在测试文件里。Foundry 测试里 `fulfillRandomWords` 调用旁的注释只写
着 `// InsuficientBalance()`,hardhat 测试里有一条更长、更惨的:
`// Here cannot run, always InsufficientBalance()`。
我当初没明白的是:mock 并不比主网宽松——它执行着同样的不变量。在它
愿意服务请求之前,订阅必须存在、必须有余额、必须把彩票合约注册为
consumer,而且 LINK 必须真的流动。mock 和 coordinator 一样记录订阅
余额——JS 测试会读 `getSubscription(...).balance` 回来确认——而在
真实链上,你的钱包用 `transferAndCall` 把 LINK 发给 coordinator,
订阅才会入账。我的订阅要么是空的,要么 consumer 还没挂上,于是
fulfill 被合理地弹了回来。
修复在测试的 setUp 里:Foundry 的 `setUp()` 在*任何测试运行之前*铸造
100 个 LINK 并为订阅充值(`LINK_BALANCE = 100 ether`):
```solidity
vm.startPrank(msg.sender);
if (block.chainid == LOCAL_CHAIN_ID) {
link.mint(msg.sender, LINK_BALANCE);
VRFCoordinatorV2_5Mock(vrfCoordinatorV2_5).fundSubscription(
subscriptionId,
LINK_BALANCE
);
}
link.approve(vrfCoordinatorV2_5, LINK_BALANCE);
vm.stopPrank();
```
部署脚本在真实链上做同样的事——`FundSubscription` 用 `transferAndCall`
送出 `FUND_AMOUNT`(3 个 LINK):
```solidity
LinkToken(linkToken).transferAndCall(vrfCoordinatorV2_5, FUND_AMOUNT, abi.encode(subId));
```
hardhat 部署脚本会给刚创建的订阅充值。这次教训花了我一个晚上:
**VRF mock 要求订阅先有资金才愿意服务请求**,而 checkUpkeep 自己的
文档注释也隐晦地这么写——"Implicity, your subscription is
funded with LINK." 随机数不是免费的,连在 mock 里也不是。
## 修复:逐段讲解合约
`Raffle` 继承自两个 Chainlink 合约,都来自 v2.5 线:
```solidity
import {VRFConsumerBaseV2Plus} from "@chainlink/contracts/src/v0.8/vrf/dev/VRFConsumerBaseV2Plus.sol";
import {VRFV2PlusClient} from "@chainlink/contracts/src/v0.8/vrf/dev/libraries/VRFV2PlusClient.sol";
import {AutomationCompatibleInterface} from "@chainlink/contracts/src/v0.8/automation/interfaces/AutomationCompatibleInterface.sol";
contract Raffle is VRFConsumerBaseV2Plus, AutomationCompatibleInterface {
```
构造函数接收 coordinator 地址、订阅 ID、gas lane(key hash)、间隔、
入场费和回调 gas 上限,除了必须变化的状态外全部 `immutable`。两个
常量很关键:`REQUEST_CONFIRMATIONS = 3` 和 `NUM_WORDS = 1`——
开奖只需要一个随机词,且要等三个区块确认。
**入场。** `enterRaffle` 刻意保持无聊:要么付足够的钱,否则回滚
`Raffle__NotEnoughETHEntered`;要么处于 `OPEN` 状态,否则回滚
`Raffle__RaffleNotOpen`。然后把发送者压栈,发出事件:
```solidity
if (msgValue < i_entranceFee) {
revert Raffle__NotEnoughETHEntered();
}
if (s_raffleState != RaffleState.OPEN) {
revert Raffle__RaffleNotOpen();
}
s_players.push(payable(msg.sender));
emit RaffleEnter(msg.sender);
```
**决定是否开奖。** `checkUpkeep` 用一行写完了整个 Automation 合约:
```solidity
bool isOpen = RaffleState.OPEN == s_raffleState;
bool timePassed = ((block.timestamp - s_lastTimeStamp) > i_interval);
bool hasPlayers = s_players.length > 0;
bool hasBalance = address(this).balance > 0;
upkeepNeeded = (timePassed && isOpen && hasBalance && hasPlayers);
```
**开奖。** `performUpkeep` 只能被网络调用,但它仍然重新检查
`checkUpkeep`——upkeep 可能拿陈旧数据被触发,纵深防御不花什么成本。
检查失败时它回滚一个自定错误,错误里自带证据:
```solidity
revert Raffle__UpkeepNotNeeded(
address(this).balance,
s_players.length,
uint256(s_raffleState)
);
```
错误数据本身就会告诉你哪个条件失败了。然后状态翻转,VRF 请求发出:
```solidity
s_raffleState = RaffleState.CALCULATING;
VRFV2PlusClient.RandomWordsRequest memory req = VRFV2PlusClient.RandomWordsRequest({
keyHash: i_gasLane,
subId: i_subscriptionId,
requestConfirmations: REQUEST_CONFIRMATIONS,
callbackGasLimit: i_callbackGasLimit,
numWords: NUM_WORDS,
extraArgs: VRFV2PlusClient._argsToBytes(
VRFV2PlusClient.ExtraArgsV1({nativePayment: false})
)
});
uint256 requestId = s_vrfCoordinator.requestRandomWords(req);
emit RequestedRaffleWinner(requestId);
```
`nativePayment: false` 表示这次请求用订阅里的 LINK 付费——这正是资金
bug 之所以要紧的原因。
**锁。** `RaffleState` 是一个枚举,`OPEN` 和 `CALCULATING`。从
`performUpkeep` 翻转它那一刻起,到 `fulfillRandomWords` 把它重置为止,
彩票处于 `CALCULATING`,`enterRaffle` 一律回滚。没人能在请求与回调
之间溜进来,所以被随机数索引的数组,恰好就是随机数抽取所面对的玩家
集合。防操纵的全部故事,就藏在这一个枚举里。
**抽赢家。** VRF coordinator 会回调 `fulfillRandomWords`,
合约覆写它:
```solidity
uint256 indexOfWinner = randomWords[0] % s_players.length;
address payable recentWinner = s_players[indexOfWinner];
s_recentWinner = recentWinner;
s_players = new address payable[](0);
s_lastTimeStamp = block.timestamp;
s_raffleState = RaffleState.OPEN;
emit WinnerPicked(recentWinner);
(bool success, ) = recentWinner.call{value: address(this).balance}("");
if (!success) {
revert Raffle__TransferFailed();
}
```
这个顺序是故意的,它就是 Checks-Effects-Interactions 模式(合约自己的
注释里点了名)。赢家、玩家数组、时间戳、彩票状态全部在 ETH 转账*之前*
重置。如果赢家恰好是个合约,它的 fallback 想重新入场:没什么可重入的
对象了——状态已经全新,转账失败则由自定错误处理,而不是一句无声的
`require` 字符串。
## 它是怎么被测试的——一套故意保留的混合测试套件
仓库为同一个合约保留了两套单元测试,因为它们抓的是不同的东西。
Hardhat/JS 套件(`Raffle.test.js`)用 hardhat-deploy
fixture、命名账户和 ethers 事件断言——它甚至在完整的端到端测试里监听
`WinnerPicked`。
Foundry 套件(`RaffleTest.t.sol`)是纯 Solidity:它 prank
coordinator mock,用
`vm.warp(block.timestamp + interval + 1)` 拨
时间、roll 区块,然后用同一种语言断言一切。两套测试独立地断言
相同的行为——hardhat 测试甚至检查
`consumers.includes(raffle.address)` 来证明订阅确实
连到了合约。
完整的开奖流程是端到端覆盖的:四个玩家入场、执行 `performUpkeep`、
从发出的日志里抓 `requestId`、经由 mock 完成 fulfill,然后断言赢家拿到
整个奖池、彩票回到 `OPEN`、时间戳前进了。
单元层周围是让「基于 mock 的测试」保持诚实的设施:一个 `LinkToken`
mock(ERC-677,带 `transferAndCall`)、Chainlink
`VRFCoordinatorV2_5Mock`,以及 `HelperConfig`,它按链构建
`NetworkConfig`。在 `LOCAL_CHAIN_ID`
(31337) 上它会自己部署 mock 并创建订阅;在 Sepolia 和主网上它持有
真实的 coordinator、gas lane 和 LINK 地址。两套工具链各有一份部署脚本:
`deploy/01-deploy-raffle.js`
(hardhat-deploy),以及 `script/DeployRaffle.s.sol`
(forge,配套 `Interactions.s.sol` 里的 `CreateSubscription`、
`FundSubscription`、`AddConsumer`)。
单元测试覆盖不了的东西,文件夹布局自己就承认了:integration 测试目录
是一份注释骨架,列着完整的金字塔——unit、integration、fork、staging、
fuzzing、formal verification。Mock 控制恰恰是你在真实网络上会失去的
东西,所以单元测试带着 `skipFork` modifier("Testnet cannot
test with Mock, don't have Mock control")——staging 阶段对着真实
Sepolia VRF 跑,练的是真正的订阅、资金和回调延迟,这些是 mock 只能
假装的东西。
最后,`.gas-snapshot` 把每个测试的成本钉死,回归被当作一个数字抓
出来,而不是一种感觉。完整的抽奖测试——从入场到付款的整条开奖流程
——是 335,011 gas:
```text
RaffleTest:testFulfillRandomWordsPicksAWinnerResetsAndSendsMoney() (gas: 335011)
RaffleTest:testPerformUpkeepUpdatesRaffleStateAndEmitsRequestId() (gas: 222486)
RaffleTest:testCheckUpkeepReturnsTrueWhenParametersGood() (gas: 74771)
```
## 前端
仓库还带了一个纯 HTML 前端 `pages/1/`,它是笔记里列出的七种与合约
交互方式的第一种(HTML/JS,然后是 Next.js + 原生 ethers、web3-react、
react-moralis、web3Modal、useDapp、wagmi)。浏览器不能
`require("ethers")`,所以页面被 browserify 打包成 bundle:
```bash
yarn browserify pages/1/indexProperCatch.js --standalone bundle -o pages/1/dist/bundle.js
```
`ProperCatch` 这个文件名本身就是重点:要修的就是
错误处理。每一个异步交互——connect、store、retrieve——都包在
`try/catch` 里,把错误 log 出来而不是让 promise 无处理地 reject;每条
路径都先检查 `typeof window.ethereum !== "undefined"`,钱包缺失时把
按钮文字换成 "Please install MetaMask"。旁边的 `index.js` 旧版本里一个
`try/catch` 都没有:交易被拒绝时 promise 只会无处理地在控制台里 reject,
而且它直接 `new ethers.providers.Web3Provider(window.ethereum)`,前面没有
任何判断。它是个小页面,但它决定了 demo
在交易被拒绝时是无声死掉,还是告诉你发生了什么。
## 重做时我会改什么
这是一个测试网学习项目,我想把边界写清楚:没有审计、没有经济攻击
建模、没有主网的钱。README 管它叫「学习项目笔记」,它就是如此。
运营上,一个真彩票有个学习项目没有的持续成本:VRF 订阅每次请求都会
消耗 LINK,Automation upkeep 也要自己的 LINK 余额。没人会自动充值——
我会写一个小监控脚本,给订阅补仓、余额太低时报警。
合约内部我会改四件事。第一,用 `randomWords[0] % s_players.length` 选
赢家在数组长度不能整除 2^256 时有模偏差——玩家少时可忽略,但真彩票
应该用拒绝采样,或者多要几个词。第二,回调忽略了 `requestId`——
`CALCULATING` 锁让重放不现实,但我会存下未完成的请求,并在
`fulfillRandomWords` 里校验。第三,赢家拿走全部余额,运营者一分不赚;
真彩票需要内置手续费或所有者分成。第四,没有暂停或紧急停止——一旦
出 bug,资金要卡到下一次开奖。
## 结果
一个数字,来自 gas snapshot:完整的开奖——四人入场、upkeep 触发、
VRF mock fulfill、赢家收款——在 Foundry 套件里跑 **335,011 gas**,
hardhat 套件又把同一个端到端行为断言了一遍。合约已部署并验证在
Sepolia 上(JS 部署的 0xc2022b56eBC140B5FebCf9FBaB14c17db4C315C4,
hardhat 部署的 0x3a827C119e1D746bb3C7bcbbf95c55246C8CcBdd)。我开头的
问题有了一个能指给人看的答案:随机数来自合约无法预测或重掷的模块,
状态机在开奖进行时锁住彩票,而自动化决定何时开奖。
我是全栈 Web 开发者和 DevOps 工程师,我构建完整的应用——
前端、后端、部署,以及那些必须持续跑下去的部分。如果你需要
一个全栈 Web 项目,或想让现有项目被可靠地部署,来找我:[WhatsApp](https://wa.me/60127972969)
· [[email protected]](mailto:[email protected]?subject=Full-stack%20web%20development)
· [hoelee.com](https://hoelee.com)。
@@ -0,0 +1,347 @@
---
title: "全链上 SVG NFT:把艺术作品放进合约里"
description: "如何用 Foundry 铸造全链上 SVG NFT:把 SVG 图片 base64 编码进合约,tokenURI() 动态生成 data URI 元数据,并从链上状态切换表情。"
pubDate: 2024-10-08
updatedDate: 2026-09-29
category: web3
tags: [solidity, foundry, erc721, nft, svg, base64, onchain]
ogImage: /og/fully-on-chain-svg-nfts.png
banner: /banners/fully-on-chain-svg-nfts.png
draft: false
---
每个 NFT 都有两半。代币本身 —— 余额、授权、所有权 —— 住在合约里,
和链一样永久。艺术是另一回事:`tokenURI()` 返回一个字符串,而大多数
集合里,这个字符串指向*别处*:一个 `ipfs://` CID,一个 HTTPS URL。
代币是永久的,它指向的东西,却是一个没有任何合约能控制的外部依赖。
这是一个学习项目,不是生产代码 —— README 里写得很明白 —— 而且这里的
一切都跑在 Sepolia 测试网上。我把它写下来,是因为全链上这个模式确实
有用,下面的数字都是真实的,代码也小到可以一口气读完。
## 如何铸造一个艺术作品永不消失的 NFT?
如果你是个刚学 Solidity 的新手,拿这个问题去搜,大部分答案都会把你
引向 IPFS,再配一句关于 pinning 的 shrug。对小尺寸的艺术品,有更好的
答案:把艺术*放进合约里*,编码好,然后给钱包一个完整的 `data:` URI,
让元数据 JSON 和图片都在同一个字符串里。这样一来,艺术作品就是链上
的字节,和代币本身没有区别。
做法有两种。你可以把原始 SVG 存进合约,每次调用 `tokenURI()` 时才做
base64 编码 —— 部署更便宜,每次读取稍微多花点 gas。或者,你可以在
部署时一次性编码,存下成品 `data:image/svg+xml;base64,` 字符串。我的
`MoodNft` 用的是第二种,因为它还要根据链上状态在两张图之间切换,
而这一切正是**动态 NFT** 的最佳入门。
## 我先尝试的做法:一个只存 URI 的 ERC-721
起点是常规的 NFT 合约。我的叫 `BasicNft`:
```solidity
contract BasicNft is ERC721 {
error BasicNft__TokenUriNotFound();
uint256 private s_tokenCounter;
mapping(uint256 => string) private s_tokenIdToUri;
constructor() ERC721("Hoelee", "HOE") {
s_tokenCounter = 0;
}
function mintNft(string memory tokenUri) public {
s_tokenIdToUri[s_tokenCounter] = tokenUri;
_safeMint(msg.sender, s_tokenCounter);
s_tokenCounter = s_tokenCounter + 1;
}
function tokenURI(
uint256 tokenId
) public view override returns (string memory) {
if (ownerOf(tokenId) == address(0)) {
revert BasicNft__TokenUriNotFound();
}
return s_tokenIdToUri[tokenId];
}
}
```
它刻意做得非常小。`mintNft(tokenUri)` 把你给的字符串按代币存起来,
`tokenURI` 原样返回;如果代币从未被铸造,就触发一个自定义错误
(比带字符串的 `require` 更省 gas,也自带文档)。我用自己的名字命名
集合,因为重点是光明正大地学习:`ERC721("Hoelee", "HOE")`。
这个合约对 URI 指向什么没有任何意见 —— OpenZeppelin 的 `ERC721` 基类
也没有 —— 所以艺术住在 URI 所在的地方。
## IPFS 托管艺术的诚实问题
对 `BasicNft` 来说,流程是标准的:把元数据 JSON 放到 IPFS 上,把它的
`ipfs://<CID>` 交给合约,让钱包和市场通过网关去解析。渲染能成功,依赖
两件事一直成立:**有人持续 pin 住那个 CID**,并且**某个网关持续提供
它**。这两件事都在任何合约的控制范围之外。pin 一旦掉了,代币还在 ——
只是再也没有艺术了。
我自己的铸造脚本就是这有多随意的最佳证据。`Interactions.s.sol` 里带了
四个示例 URI:两个原生的 `ipfs://` CID,两个
`https://gateway.pinata.cloud/ipfs/...` URL。
README 在这个话题上的原话是:优先用原生 CID 形式,这样资产不依赖任何
单一网关。原文是:
"so the asset resolves independent of any single gateway"
网关 URL 是彻头彻尾的单一故障点。原生 CID 好一点,但它仍然依赖某个 pin 存在。
而保存下来的铸造日志显示,`BasicNft` 的 0 号代币实际存进去的是:
```text
ipfs://QmW1aRxvAngY22wrxyrUYSriekkHQMcXA3D1mjHgBc5ge6?filename=MrHoelee.png
```
这个 CID 指向的是一个我自己都没在跑的 pin。我没法保证那个节点 —— 或者
现在 pin 着它的任何人 —— 一直在线。「代币是永久的」和「艺术只是一句
承诺」之间的这道裂缝,正是我想要消除的东西。
## 修复方案:部署时把艺术编码进合约
`MoodNft` 把艺术本身存进合约。编码在部署脚本里完成:它读取 `img/`
下的两个 SVG 文件,在构造函数被调用之前,就把每一个都变成
`data:image/svg+xml;base64,` URI:
```solidity
function run() external returns (MoodNft) {
string memory svgSmile = vm.readFile("img/smile.svg");
string memory svgSad = vm.readFile("img/sad.svg");
string memory imageUriSmile = svgToImageUri(svgSmile);
string memory imageUriSad = svgToImageUri(svgSad);
vm.startBroadcast();
MoodNft moodNft = new MoodNft(imageUriSmile, imageUriSad);
vm.stopBroadcast();
return moodNft;
}
function svgToImageUri(
string memory svg
) public pure returns (string memory) {
string memory baseURL = "data:image/svg+xml;base64,";
string memory svgBase64Encoded = Base64.encode(
bytes(string(abi.encodePacked(svg)))
);
return string(abi.encodePacked(baseURL, svgBase64Encoded));
}
```
两个细节很关键。`vm.readFile` 是 Foundry 的 cheatcode,在脚本运行期间
从磁盘读文件,所以合约里永远不会出现手写生成再粘贴进去的 base64 块
—— 链上的艺术可以被证明就是 `img/` 里的艺术。而 `Base64` 来自
OpenZeppelin 的 utils,我也不用自己写编码器。
构造函数随后把两个现成的 URI 存为状态:
```solidity
constructor(
string memory happySvgImageUri,
string memory sadSvgImageUri
) ERC721("MoodNft", "MN") {
s_tokenCounter = 0;
s_sadSvgImageUri = sadSvgImageUri;
s_happySvgImageUri = happySvgImageUri;
}
```
`mintNft()` 不收任何 URI:它 `_safeMint` 铸造,把新代币记为
`Mood.HAPPY`,存进 `mapping(uint256 => Mood)`,然后计数器加一。
艺术在部署那一刻就定下来了,而不是铸造的时候。
## tokenURI 动态构建整个 NFT
这就是模式的核心:
```solidity
function tokenURI(
uint256 tokenId
) public view override returns (string memory) {
string memory imageURI;
if (s_tokenIdToMood[tokenId] == Mood.HAPPY) {
imageURI = s_happySvgImageUri;
} else {
imageURI = s_sadSvgImageUri;
}
string memory tokenMetadata = string.concat(
'{"name":"',
name(), // You can add whatever name here
'", "description":"An NFT that reflects the mood of the owner, 100% on Chain!", ',
'"attributes": [{"trait_type": "moodiness", "value": 100}], "image":"',
imageURI,
'"}'
);
string memory tokenURIJson = string(
abi.encodePacked(
_baseURI(),
Base64.encode(
bytes(abi.encodePacked(tokenMetadata))
)
)
);
return tokenURIJson;
}
function _baseURI() internal pure override returns (string memory) {
return "data:application/json;base64,";
}
```
跟着走一遍它返回的东西:JSON 里的 `"image"` 字段本身就是一个
`data:image/svg+xml;base64,` URI —— 是艺术本身,不是指向艺术的指针。
然后整个元数据 JSON 再做一次 base64 编码,`_baseURI()` 在前面加上
`data:application/json;base64,`。于是 `tokenURI()` 返回一个完全
自包含的字符串。钱包解码它,就同时拿到名字、描述、属性和图片字节,
没有任何东西还需要去取。整条链路里没有 IPFS、没有 HTTPS、没有网关。
## flipMood:由链上状态驱动的动态 NFT
因为情绪就是*状态*,改变状态就改变了艺术:
```solidity
function flipMood(uint256 tokenId) public {
if (
getApproved(tokenId) != msg.sender && ownerOf(tokenId) != msg.sender
) {
revert MoodNft__NotOwnerOfToken();
}
if (s_tokenIdToMood[tokenId] == Mood.HAPPY) {
s_tokenIdToMood[tokenId] = Mood.SAD;
} else {
s_tokenIdToMood[tokenId] = Mood.HAPPY;
}
}
```
门槛是持有者*或*被授权地址 —— `getApproved()` 来自 `ERC721`,免费获得
—— 其他人一律得到 `MoodNft__NotOwnerOfToken()`。翻转之后,同一个
token id 的 `tokenURI` 就开始返回另一张图。同一个代币,不同的面孔,
全部在链上。这是最小可能的动态 NFT,也是思考「由游戏状态或链上事件
驱动的艺术」时很好的起点。
## 测试教会我的 Solidity 细节
**Solidity 里不能用 `==` 比较两个 `string`。** Solidity 只能比较值
类型;`string` 是动态字节数组。惯用修法是比较哈希,我的测试文件甚至
用注释写明了这一点:
```solidity
// string is array of bytes can't directly compare
// we can compare: bool, uint256, address, bytes32
assert(
keccak256(abi.encodePacked(expectedName)) ==
keccak256(abi.encodePacked(actualName))
);
```
统一用哈希比较:
`keccak256(abi.encodePacked(a)) == keccak256(abi.encodePacked(b))`
这个模式在测试套件里到处都是,也是链上需要比较字符串时伸手就该拿的。
**测试分两层。** 数一遍仓库里的测试函数:四个测试合约,一共六个测试。
`MoodNftTest` 是纯单元测试:直接用常量 base64 URI 构造 `MoodNft`。
另外三个 —— `DeployMoodNftTest`、`BasicNftTest`、
`MoodNftIntegrationTest` —— 实例化部署脚本并调用
`deployer.run()`,走的都是真实路径:在真正的 `img/*.svg` 上执行
`vm.readFile`、编码、部署。也就是说,连「单元」部署测试都在拿真实的
艺术文件验证 base64 流水线。
**身份伪装是两个 cheatcode。** `makeAddr("HOELEE")` 是从标签推导出的
确定性假地址 —— 不需要管理任何密钥。`vm.prank(USER)` 让*下一次*调用
看起来来自那个地址。两者配合,不需要钱包就能走通正常路径:
```solidity
function testFlipTokenToSad() public {
vm.prank(USER);
moodNft.mintNft();
vm.prank(USER);
moodNft.flipMood(0);
assertEq(
keccak256(abi.encodePacked(moodNft.tokenURI(0))),
keccak256(abi.encodePacked(SAD_SVG_URI))
);
}
```
**铸造脚本应该找到合约,而不是硬编码地址。** `Interactions.s.sol` 用
`DevOpsTools.get_most_recent_deployment("BasicNft", block.chainid)`,
它会读 `broadcast/` 日志,返回当前链上该合约最近一次部署的地址:
```solidity
address mostRecentDeployed = DevOpsTools.get_most_recent_deployment(
"BasicNft",
block.chainid
);
mintNftOnContract(mostRecentDeployed);
```
我自己的脚本里还留着注释掉的 Sepolia 和 Anvil 硬编码地址 —— 这正是
这个模式存在要消灭的习惯。地址要从部署日志里拿,否则总有一天你会
给一个过期的合约铸造。
## 如果重来,我会怎么做
**链上艺术要付部署 gas,而且是永久的。** 两张 SVG 的每一个字节都在
部署时一次性付清,然后以构造函数字符串的形式永远住在合约存储里。
这是一次性成本,但它是真实的,并且随艺术尺寸增长。
**能放进去的艺术有上限。** EIP-170 把合约代码限制在 24,576 字节以内,
艺术字节要和逻辑共用这个预算。笑脸和哭脸这种矢量 SVG 是理想选择 ——
小、清晰、可缩放。照片或 3D 模型永远塞不进去,硬塞也只是浪费 gas。
我的 `BasicNft` 部署返回 `4102 bytes of code`;
`MoodNft` 的字节码还要再叠上两张 SVG。
**不是每个项目都该这么做。** 如果艺术很大、集合需要可变元数据、或者
部署预算紧张,IPFS 或文件存储加一个*可设置的* base URI 才是务实的默认
选择 —— `ERC721` 的 `_baseURI()` 钩子让这个模式也很容易。该问的正确
问题是:*这份艺术必须在所有 pin 服务都消亡后依然存活吗?* 如果是,
而且它放得进合约,那就上链。否则,别为这些字节付钱。
**锁死 SVG 的行尾。** 仓库通过 `.gitattributes` 把
`img/*.svg` 固定为 LF,保证编码后的字节是确定的;在 Windows 上,
`core.autocrlf` 会悄悄注入 CRLF,改变编码后的艺术。这种「艺术就是
字节」的模式,对这种隐形 bug 毫不留情。
**从第一天起就用 DevOpsTools。** 对单机学习来说,注释里的硬编码地址
没关系;一旦有第二次部署,它们就是陷阱。
## 量化的结果
仓库里保存了一份部署日志,是 `BasicNft` 部署到 Sepolia 的记录,所以
下面这些数字和文件里完全一致:
| Item | Value |
|---|---|
| Chain | `11155111` |
| Constructor trace | `[868596] → new BasicNft` |
| Deployed bytecode | `4102 bytes of code` |
| Deployment tx | `993568 gas * 0.538650187 gwei` = `0.000535185588997216 ETH` |
| Block | `6522146` |
| Deployed at | `0x84F0Ee970BD49FCf1b8Cd637EF4e4755DBE74e0E`, auto-verified on Etherscan |
把存了 IPFS URI 的 0 号代币铸造出来,花了 `181874 gas` ——
`0.000186204671471742 ETH`。也就是说,在测试网上把 NFT 放上去几乎不
花钱;IPFS 依赖的代价是隐形的,直到某个 pin 死掉的那一天。
而 `MoodNft` 真正重要的结果根本不需要日志:在 MetaMask 里把部署好的
合约和 0 号代币作为 collectible 添加,艺术作品就直接从链上 base64 SVG
渲染出来 —— 没有 IPFS 网关、没有网络查找、没有任何需要维持在线的东西。
同一个代币,`flipMood` 一按就换脸。艺术唯一依赖的「服务器」就是
区块链本身,而任何一个节点都跑着一份完整的链。
## 需要这类工作吗?
我是一个全栈 Web 开发者和 DevOps 工程师。这类链上 demo 是我学习的地方,
但我为企业做的工作是全栈 Web 开发 —— 网站、Web 应用、API,以及它们
背后的自托管栈和部署流水线。如果你需要一个从零开始的项目,或者想
用一个小的链上概念验证来验证想法,告诉我你想做出什么:
[WhatsApp](https://wa.me/60127972969) ·
[[email protected]](mailto:[email protected]?subject=Web%20development%20project) ·
[hoelee.com](https://hoelee.com)。
@@ -0,0 +1,259 @@
---
title: "那个把静默丢失更新变成 409 的 JPA 字段:@Version 乐观锁"
description: "在 REST + JPA 的读-改-写流程里,两个编辑同时保存同一条记录时,后保存者会静默覆盖先保存者的修改,而 API 两次都返回成功——JPA 的 @Version 乐观锁把这个静默丢失更新变成 HTTP 409 冲突:请求携带期望版本号,版本不符就拒绝写入,并提示客户端重新读取再试。"
pubDate: 2026-08-19
category: engineering
tags: [java, spring, jpa, hibernate, rest, testing]
ogImage: /og/jpa-version-field-lost-update.png
banner: /banners/jpa-version-field-lost-update.png
draft: false
---
两个人同时打开同一篇文章,两个人都改了,两个人都保存。其中一次
保存悄无声息地消失了——没有报错,没有警告,而且 API 对两次请求
都返回了 HTTP 200。
这就是丢失更新(lost update)问题,是读-改-写(read-modify-write)
API 里最安静的数据丢失 bug。我在做 Spring Boot 作品集 demo 时撞上
了它——这是一个 Spring Boot 3.5.16(Java 21)的 REST API,编辑器
从浏览器页面创建和更新文章。修法最终落在三处:JPA 实体上的一个
`@Version` 字段、请求契约里的一个字段、以及 API 层把它映射成
HTTP 409 Conflict 的一个异常。这篇文章就沿着真实代码走一遍这条路。
## 问题:一条编辑消失了,却谁都怪不上
为什么两个人保存同一条记录时,其中一个人的修改会消失?
跟着时间线走。编辑器 A 和编辑器 B 都拉取了同一篇文章。A 先保存:
行被更新,API 返回 200。B 稍后保存:行被*再次*更新,这次覆盖了
A 的文字,API 又返回 200。每个请求都成功;每个响应都告诉它的调用
方「你的保存就是这条记录的当前状态」——对其中一个人来说这是假话。
A 的编辑就这么没了,两个客户端都没有任何办法知道。
失败是静默的,因为它就是读-改-写流程的正常行为,不是异常。这正是
它在真实系统里存活的理由,也是为什么在要展示工程判断力的东西里,
值得把它修得干净。
## 我先试的方案:不带版本号的读-改-写,以及它为什么失败
我对更新流程的第一版草图是最直白的那种:`GET` 记录、编辑、
`PUT` 回去,只带新的标题和正文,什么都不带。
```text
GET /api/posts/{id} # 读取记录
PUT /api/posts/{id} # 写回新的标题和正文
```
服务端加载行、套用更新、返回 200。它无从知道在这一行数据上,客户端
在 `GET` 和 `PUT` 之间已经被别人动过——读取和写入是两个互不相干的
请求,契约里没有任何东西把它们连起来。最后写入的人赢,而两个写入
者都被告知成功。
在这里,200 比报错更糟。报错至少告诉输掉的那个客户端「出事了」,
给他们一个去查的理由。200 则告诉两个客户端「你们的保存就是记录
当前的状态」——对其中一个是撒谎。输掉的那次编辑现在哪儿都不存在:
屏幕上没有,数据库里也没有;而因为客户端相信自己保存成功了,
没有人会去找这份数据。
## 修法:一个 @Version 字段、一份契约、一个冲突
修法一共五小步,全部都在 demo 的源码里。
### 1. 实体随身携带一个版本列
```java
@Entity
@Table(name = "posts", indexes = @Index(name = "idx_posts_title", columnList = "title"))
@EntityListeners(AuditingEntityListener.class)
public class Post {
@Id
@GeneratedValue
@UuidGenerator
private UUID id;
@Column(nullable = false, length = 160)
private String title;
@Column(nullable = false, length = 10_000)
private String body;
@Version
private long version;
// ...
}
```
`@Version` 告诉 Hibernate 把 `version` 当作乐观锁。它生成的每一条
`UPDATE` 都变成有条件的:
```sql
UPDATE posts
SET title = ?, body = ?, version = version + 1, ...
WHERE id = ? AND version = ?
```
如果这行数据的版本号已经和语句构造时的不一致,受影响行数为零,
Hibernate 就会拒绝这次 flush,而不是悄悄覆盖。实体从不用手自增
计数器——更新方法只碰 `title` 和 `body`;版本号跟着写入一起走。
### 2. 版本号进入 API 契约
锁如果客户端无法参与,就没有用,所以版本号在两个方向上都穿过
API。响应的 record 暴露它:
```java
public record PostResponse(UUID id, long authorId, String title, String body,
long version, Instant createdAt, Instant updatedAt) {
}
```
更新请求则要求把它带回来:
```java
public record UpdatePostRequest(
@NotBlank @Size(max = 160) String title,
@NotBlank @Size(max = 10_000) String body,
@NotNull @Min(0) Long version) {
}
```
`UpdatePostRequest` 旁边的学习注记说得很直白:
「the expected version is part of the update
contract, making optimistic locking visible to clients.」
(期望的版本号是更新契约的一部分,让乐观锁对客户端可见。)
### 3. 在拥有写事务的 service 里检查版本
```java
@CachePut(cacheNames = "posts", key = "#postId")
@Transactional
public PostResponse updatePost(UUID postId, UpdatePostRequest request) {
Post post = postRepository.findById(postId)
.orElseThrow(() -> new PostNotFoundException(postId));
if (post.getVersion() != request.version()) {
throw new PostVersionConflictException(postId);
}
post.update(request.title().trim(), request.body().trim());
return PostResponse.from(postRepository.saveAndFlush(post));
}
```
读取、比较、保存全部发生在一个 `@Transactional` 方法里——这正是让
检查诚实的那部分:版本号是和*写入那一刻*的行状态比较,而不是和
早前某个请求留下的快照比较。如果读取和写入分属不同事务,检查到的
就是这行数据已经离开的旧版本,整套机制就成了表演。`PostService`
的学习注记点名了这条规则:「service methods
centralize transaction boundaries, cache coherence, and
optimistic-locking rules.」(service 方法集中管理事务边界、缓存
一致性与乐观锁规则。)demo 还开着
`spring.jpa.open-in-view: false`,所以不会有残留的会话跨请求地
供给过期实体——行是在写入事务内部重新读取的。
显式的检查会确定性地挡掉过期请求。`@Version` 列在底层仍然有意义:
如果检查与 flush 之间恰好插进一次别的提交,那条有条件的 `UPDATE`
影响零行,Hibernate 照样拒绝这次写入。
### 4. 异常处理器把它映射成 409
异常本身带着客户端能直接照做的信息:
```java
public class PostVersionConflictException extends RuntimeException {
public PostVersionConflictException(UUID postId) {
super("Post %s has changed; fetch it again before retrying".formatted(postId));
}
}
```
错误边界把它转成正确的 HTTP 应答:
```java
@ExceptionHandler(PostVersionConflictException.class)
ProblemDetail handleConflict(PostVersionConflictException exception) {
return problem(HttpStatus.CONFLICT, "POST_VERSION_CONFLICT", exception.getMessage());
}
```
`HttpStatus.CONFLICT` 就是 HTTP 409,响应是一份 problem
document——一个 `type` URI、一个稳定的机器码
(`POST_VERSION_CONFLICT`)和一段人类可读的说明。demo 在配置里
开启了框架的 problem-details 支持
(`spring.mvc.problemdetails.enabled: true`),所以校验错误和所有
其他错误都共用同一种响应形状。
### 5. 整条链路,从头到尾
```text
过期的 PUT /api/posts/{id}
-> PostService.updatePost 在事务内部重新读取这一行
-> post.getVersion() != request.version()
-> PostVersionConflictException: "fetch it again before retrying"
-> ApiExceptionHandler.handleConflict
-> HTTP 409, ProblemDetail with code POST_VERSION_CONFLICT
-> 客户端知道自己的前提过期了
```
一个 `@Version` 字段、请求契约里的一个 `version`、一个异常、一个
handler 方法。Controller 什么都没改——`updatePost` 看起来还是普通
的 `PUT`。
## 为什么答案是冲突,而不是重试
当两个编辑器产出同一份记录的两种不同版本时,服务端无法知道哪一方
的意图才是被需要的。从各自作者的角度看,两次保存都正当;重放那条
过期的写入,只是重放同一次数据丢失。
诚实的结论就是 HTTP 对 409 的定义:请求与资源的当前状态冲突,
并且客户端被明确告知如何解决——「fetch it again before retrying」
(重新取一次再试)。输掉的客户端重新读取记录,看到另一位作者的
修改,由人来决定保留什么。静默覆盖变成了一处看得见的决策点——这
正是这件事的全部意义。
## 这个 demo 的其余部分证明了什么
版本字段只是一个小系统里的一层,而其余部分都能用同样的方式核实
——下面每一行都是仓库里的一个类或配置文件,不是一句口号:
| 层 | 做了什么 | 在哪 |
|---|---|---|
| 缓存 | 单篇文章读取使用有界 Caffeine 缓存(上限 500,TTL 10 分钟,都来自配置);读取用 `@Cacheable`、更新用 `@CachePut`、删除用 `@CacheEvict`;`GET /api/showcase/cache` 暴露请求数、命中数、未命中数和命中率 | `CacheConfig`、`CacheProperties`、`ShowcaseMetricsController` |
| 安全 | 无状态 HTTP Basic + BCrypt;页面与读 API 公开,所有写操作需要 `EDITOR` 角色;本地默认是专用的非机密 `demo-editor`/`changeit` 账户,可用环境变量覆盖 | `SecurityConfig`、`application.yml` |
| 错误 | 请求 record 在边界处校验;一个 `@RestControllerAdvice` 返回一致的 problem document 和机器码——`POST_NOT_FOUND`(404)、`POST_VERSION_CONFLICT`(409)、带字段错误表的 `VALIDATION_FAILED`(400) | `CreatePostRequest`、`UpdatePostRequest`、`ApiExceptionHandler` |
| 配置 | 本地 profile 使用内存 H2 数据库(PostgreSQL 模式),`ddl-auto: update` 并预置示例文章;`prod` 选择 PostgreSQL,所有凭据来自 `APP_*` 环境变量,`ddl-auto: validate` | `application.yml`、`application-prod.yml` |
| 测试 | 一个 `@SpringBootTest` 套件断言:渲染的 Thymeleaf 页面、未认证 401 对编辑器 201、`VALIDATION_FAILED` 问题响应、公开搜索,以及重复读取后的缓存命中 | `DemoApplicationIntegrationTest` |
## 如果做真东西,我会怎么改
一旦这不再是本地 demo,有两件事立刻要变。项目 README 里两件都写了,
值得作为我自己的判断再重复一遍。
第一,schema 管理。我会在任何时候把 `ddl-auto` 设为 `validate` 之前,
先加 Flyway 或 Liquibase 迁移。demo 默认 profile 用的是 `update`,
在临时数据库上很方便,养成习惯就很危险——生产 schema 应该是
有版本管理的代码,而不是启动时的副作用。
第二,密钥。本地编辑器账户是刻意的非机密 demo 账户,带默认凭据。
做真东西的话,我会把凭据放进托管的密钥存储(managed secret store),
拿不到凭据就拒绝启动,而不是回退到默认值。`prod` profile 已经做到
所有凭据来自环境变量、不提交任何密钥——这正是我想要的形态,
只是不要那些回退值。
## 结果
过期的更新现在返回 HTTP 409,机器码 `POST_VERSION_CONFLICT`,而不是
一个静静丢掉另一位作者修改的 200。丢失不再可能悄悄发生:失败是
响亮的、可操作的——「fetch it again before retrying」——并且集成
测试套件端到端地断言了访问与校验行为。
整个修法就是一个字段上的一个注解,加上让版本号成为契约一部分的
管道代码。如果一条记录可能同时被两个人读取,这个字段就是「编辑
丢失」与「双方都看得见的冲突」之间的分界线。
我平时就做 Java/Spring 后端和全栈 Web 应用——从浏览器页面到数据库
的 REST API:JPA、乐观锁、缓存、校验和一整套集成测试。如果你的表单
或字段里也有「编辑悄悄消失」的问题,跟我说说:
[WhatsApp](https://wa.me/60127972969) ·
[[email protected]](mailto:[email protected]?subject=Spring%20Boot%20help) ·
[hoelee.com](https://hoelee.com)。
@@ -0,0 +1,239 @@
---
title: "逐页验证一份 19 页的 PDF 报告"
description: "如何逐页验证生成的 PDF 报告:用浏览器打印同一份 HTML 作基线,逐页比对几何尺寸,把每一处偏差都压到 2pt 以内。"
pubDate: 2026-09-28
updatedDate: 2026-09-29
category: engineering
tags: [php, codeigniter, pdf, css, print, testing]
ogImage: /og/verifying-a-pdf-report-page-by-page.png
banner: /banners/verifying-a-pdf-report-page-by-page.png
draft: false
---
你怎么知道一份生成出来的 PDF 每一页都真的正确——不只是第一页?
我维护着一个 CodeIgniter 4 应用:输入一个人的出生信息,生成一份
中文命理/生命密码报告——十九张密排的 A4 sheet,封面、数字图表、
方向九宫格、「一到九」的个人特征页。以前这份报告按网站原本的设计
交付:操作员在浏览器里按 Ctrl+P,手工另存成 PDF。我把它换成了
服务端渲染(mPDF),一夜之间,产品的质量取决于一个我看不见
如何排版的引擎。
这就是这个问题可被搜索的版本:如何把一份多页 PDF 报告逐页地、
用数字而不是肉眼,去和浏览器自己的打印输出对版?这个过程抓到
了真实的 bug——包括一行被渲染成 2.5pt、从第二页起几乎看不见的
页眉——最后全部十九页与浏览器基线的偏差都压进了 2pt。
## 为什么「看起来没问题」不算测试
没有人能用肉眼验证一份 19 页的报告。过去的流程是:打开 PDF,
扫一眼封面,交付。第 17 页标题漂移、页脚插图压住版权行、页眉
小到读不出来——快速翻一遍永远发现不了,而花钱买报告的顾客
看到的是全尺寸。
天真的做法会失败,是结构性的原因。服务端 PDF 渲染器不会像
浏览器那样排你的 HTML:分页、页边距、基线、字距全都会漂移——
而且不存在一个「看起来没问题」的测试,能让你在下周有人改了
CSS 之后再跑一次。我第一次单趟渲染出来是 27 页,而设计预期
是 19 页:mPDF 没有 CSS 裁剪,而 sheet 靠 `overflow: hidden`
藏住溢出。报告的 `@page` 规则更糟:mPDF 为这条规则本身
翻了一页,19 张 sheet 炸成 12,789 页。页数都不稳定,所以
「扫一眼第一页」不是验证——是碰运气。
## 基线:让浏览器去打印你的 HTML
操作员的 Ctrl+P,就是 Chrome 用打印 CSS 打印报告自己的 HTML。
所以参照标准不是「我觉得它应该长什么样」,而是浏览器自己的
打印输出——在同一台机器、同一套网页字体下生成。做法:把报告
目录用本地静态服务起起来(字体必须同源,否则 webfont 拒绝
加载),再用 headless Chrome 打印。报告 CSS 声明了
`@page { margin: 0 }`,所以浏览器输出是无边距满幅,再配合
`-webkit-print-color-adjust: exact` 保住背景图——等价于操作员
勾选「背景图形」:
```bash
python -m http.server 8123 --bind 127.0.0.1 --directory <report-dir>
"C:\Program Files\Google\Chrome\Application\chrome.exe" --headless=new --disable-gpu \
--no-pdf-header-footer --user-data-dir=%TEMP%\chromeprofile --virtual-time-budget=30000 \
--print-to-pdf=chrome-win.pdf "http://127.0.0.1:8123/report-local.html"
```
然后在 VM 上,用同一份 HTML 出服务端版本:
```bash
php tools/pdf-render.php /tmp/report-v10.html /tmp/mpdf.pdf
```
## 逐页比对,而不是整体对比
以浏览器的 PDF 为基准,我在 Windows 这边用
`uv run --with pymupdf` 逐页对比两份文档:页面尺寸、文字条数、
图片包围盒(x0/y0/宽/高)、附图说明文字的 y 坐标。判定阈值:
**偏差 ≤2pt(0.7mm)算对齐**;超过 5pt 就要查根因。而且只比
「同一元素在两份 PDF 里的差」——永远不要比绝对页数,因为页数
一致是前提,不是测试本身。
## 逐页比对抓到了什么
下面每个 bug 都有可测量的前后数字,而且都修在库或模板里——
不是用管道糊过去。
**看不见的页眉行。** 从第二页起每页顶部有一行小字,用户直接
反馈说小到读不出来。页眉是一个 `font-size: 10px` 的 div,
里面套一张 auto 宽的 table,第一格写着 `width: 100%`。mPDF
把这解读成「表格超宽」,于是连同字号一起把整行缩到三分之一:
**2.5pt,而 Chrome 是 7.5pt**。修法(`fixPageHeaderTables()`):
给表格显式宽度 + 显式字号(px→pt),并去掉第一格的
`width: 100%`:
```php
$pt = round((float) $m[2] * 0.75, 2); // 10px = 7.5pt
```
**匹配过宽的外边距规则。** `.sheet { margin: 5mm auto }` 是为
屏幕预览准备的(sheet 之间的阴影缝隙)。mPDF 当真了,把
296mm 的 sheet 推到 301mm——越过 297mm 的页面——于是 sheet
在页边被切开,auto-fit 把整页缩小 3%,带白边。最直观的修法、
在样式表末尾追加一条 `.sheet { margin: 0; }`,完全没用:mPDF
对同名选择器的两条规则只认「先出现的赢」。所以库要原地改写
这条规则(`stripSheetMargins()`),而匹配器必须小心什么才算
「sheet 规则」。护栏是一个负向回顾断言:
```php
'~(?<![\w.\-])\.sheet\s*\{([^}]*)\}~i'
```
只匹配独立的 `.sheet` 规则——像 `.invoice-sheet` 这种只是名字
以 `-sheet` 结尾的选择器不会被碰,剪边距的逻辑就毁不掉无关
规则。(收据文档就是独立的单页文档,下面会讲。)
**页脚插图偏了最多 490pt。** sheet 用 `position: absolute;
bottom: Npx` 把插图钉在页底。mPDF 只在文档顶层认绝对定位,
所以进了 sheet 之后这些图片退回普通流:**高了 30–490pt**
(第 6 页偏了 213.5pt,也就是 75mm),而且**窄了 10%**
(450pt vs Chrome 的 499.5pt)——因为百分比宽度是按 sheet
的 189mm 内容盒算的,而 Chrome 按 210mm 的包含块算。修法:
把所有钉底的图片从 sheet 里抽出来,放进 mPDF 自己的 HTML
footer(`SetHTMLFooter()`)——按页锚定、不占正文流,几何
统一按页面盒换算。结果:**≤2pt**。「窄 10%」也一起消失了,
因为两者同一个根因。
**22 处居中内容全部左对齐。** 模板用旧式 `<center>` 标签
居中,而 mPDF 8 的 `Center` 标签处理器是空类——标签整个被
丢弃,所有居中的标题和表格全部左对齐(「前言」实测 x=30,
Chrome 是 x=280)。`expandCenterTags()` 把 `<center>` 改写成
`<div style="text-align:center">`,并给居中块里的表格补上
`align="center"`——因为父级的 `text-align` 传不到表格。
修复后:x=281,Chrome 280。
**行距比浏览器高 15%。** 模板的 normalize.css 声明了
`html { line-height: 1.15 }`;mPDF 不继承它,退回自己的字体
度量(1.33)。这累积成每页下半部 20–40pt 的漂移——sheet 3
甚至溢出到第二页,触发整页缩小。把 `useFixedNormalLineHeight`
设成模板自己的值之后,正文行距 15.5pt,Chrome 是 15.7。
表格还要显式加 `td, th { padding: 1px }`——mPDF 默认的
单元格内边距比浏览器大 2px。
**字体是错的。** mPDF 读不了网页用的 `.woff`,文字回退到
自带的 Sun-ExtA;而正文栈里的「微软雅黑」在 Linux 服务器上
根本不存在。我注册了三套真 TTF——标题手写体 MaShanZheng、
拉丁与数字 Roboto、正文 CJK 用 wqy-microhei——并把模板的
字体栈映射过去。一个坑:必须关掉 mPDF 的自动按脚本选字,
否则它挑「第一个支持中文的已注册字体」——整页正文都变成
手写体。
**还有一个与 mPDF 无关的 bug。** 第 7 页的主插图在**两份**
PDF 里都是破图——比对显示 Chrome 和 mPDF 里是同一个破损
占位块。模板硬编码了 `https://cdn.hoelee.com/...`,这个域名
已经不再解析(NXDOMAIN),所以每个引擎都抓不到图。把模板
改回应用自己的 base URL,并让 `localiseAssets()` 把任何
host 的 `/static/` 路径都映射到本地文件,两个引擎里的插图
都恢复了。只有并排比对才能暴露这一类 bug——单独看哪个引擎
都「正常」。
## 一张 sheet,一页
渲染器这么设计是有意的:库把 HTML 按 `<section class="sheet">`
切开,每张 sheet 单独渲染成一份单页文档,合并时只取第 1 页——
物理上保证「一张 sheet = 正好一页 A4」,永远不会跨页断裂。
mPDF 量中文宽度和 Chrome 略有不同,个别 sheet 会高出几毫米。
与其丢内容,库按一把缩放梯子——1.0, 1.005, 1.01, 1.02, 1.03,
1.06, 1.10, 1.15, 1.22——取第一个能落进一页的比例,缩小整张
sheet 而不是裁内容。隐藏溢出是浏览器打印做的事
(`overflow: hidden`);一份收费产品如果 PDF 里静默丢了页内
内容,就是无声的交付事故,所以设计选择是绝不丢内容。sheet 3
现在需要 x1.005——0.5%,肉眼不可见——以前是 x1.03。
**「19/7 页」是什么意思。** 报告模板永远按固定顺序渲染十九张
sheet;「版本」是这些 sheet 上的一个过滤器,不是第二份模板。
完整版是 **19 页**;RM49 的精华版是**这 19 张里的 7 张**,
页码重编,每张入选的 sheet 都登记了一个文字标记——模板被
改动/重排导致取错页时会大声失败,而不是把错误的章节交给
顾客。两个版本走同一条管线,也用同一种方式验收:
`tools/pdf-verify.php --expect=19` 和
`--expect=7` 双双 PASS
——页数加逐页 ink 检查(ghostscript 50dpi)证明没有任何
空白页。
## 为什么收据是独立文档
收据不是报告裁剪出来的。它是自己的一份单页 A4 文档:真
16mm 页边距(报告刻意做满幅无边距)、三语、单趟渲染——
因为里面没有 sheet。它在发「已收款」邮件时才懒生成:付款
回调必须毫秒级应答,0.3–1 秒的 PDF 渲染不属于回调。它落进
同一个交付存储,文件名带 `-receipt` 后缀,永远不会和报告
文件撞名,而且幂等:重试、重寄、顾客自己来拿,拿到的都是
同一份。做它的过程从另一个方向印证了同一个论点——连单页
文档都和浏览器不一样:`<small>` 上的 `display: block`
不生效,左右并排的两张表把右列的数值裁出页面右边界,
合计行必须写在明细表内部,否则标签会浮在半空。
## 我会怎么做不一样
对版方法现在躺在项目笔记里,是一份文档化的流程,不是仓库
里的脚本——这就是差距。仓库里自动化的验收工具只证明页数和
逐页 ink,永远抓不到 2.5pt 的页眉或 15% 的行距漂移。我会把
浏览器基线比对做成仓库验证工具链里的一个脚本:用同一份
HTML 分别喂 Chrome 和库,diff 几何,任何超过 5pt 的偏差
直接失败。这样,一次让打印布局悄悄回退的 CSS 改动会在构建
时炸掉,而不是送到顾客手里。
我还会在写任何 mPDF 补偿代码之前就先出 Chrome 基线。
「用操作员用的同一个引擎打印,然后测量」才是解锁点;之后
每个修复都是机械活。还有两件诚实的遗留工作:伴侣合盘与
家庭套餐还没跑过这套逐页对版,19 页的 PDF(背景图加嵌入
字体约 20 MB)也还需要在交付前压缩。
## 结果
修复后的完整版验收:
```text
$ php tools/pdf-verify.php /tmp/report-v10.html --expect=19
out : 20,225,818 bytes, 19 pages, 10.4s, peak 188 MB
per-page ink check: all pages have content | report pages=19
expected 19 pages => MATCH
RESULT: PASS
```
- **19 页 vs Chrome 基线的 19 页 — MATCH**,一张 sheet 一页,
无跨页断裂。
- 每一项实测偏差都**在 2pt(0.7mm)以内**:第 6 页偏了
213.5pt 的页脚插图、从 2.5pt 恢复到浏览器同款 7.5pt 的
页眉、归位的居中标题、15.5pt vs Chrome 15.7 的正文行距、
与网页字体一致的嵌入字体。
- ink 检查确认**没有任何空白页**,PDF 文字可提取——封面能
读出真实文字,这对一份顾客要复制内容的报告很重要。
- 精华版:**7 页 PASS**,4.99 MB,2.8 秒。
这就是「第一页看起来没问题」和「全部十九页都在浏览器两个点
以内」的区别。前者是肉眼扫一遍就交付的结果;后者是拿浏览器
自己当测试得到的结果。
---
我平时就做这类 Web 应用与打印/PDF 报告管线,也做网站设计与
开发。如果你有一份服务器渲染的文档——报告、收据、发票——
想在它送到顾客手里之前确认每一页都正确,跟我说说:
[WhatsApp](https://wa.me/60127972969) · [[email protected]](mailto:[email protected]?subject=PDF%20report%20pipeline) ·
[hoelee.com](https://hoelee.com)。
@@ -0,0 +1,159 @@
---
title: "为什么在 Windows 上克隆后,我的链上 NFT 图像变了"
description: "为什么我在 Windows 上克隆后链上 NFT 的图像变了:core.autocrlf 往 vm.readFile 读取的 SVG 里注入 CRLF,base64 编码随之改变,而一个 eol=lf 规则就修好了它。"
pubDate: 2026-08-19
category: web3
tags: [foundry, solidity, svg, base64, git, windows, crlf]
ogImage: /og/why-my-on-chain-nft-art-changed-on-windows.png
banner: /banners/why-my-on-chain-nft-art-changed-on-windows.png
draft: false
---
一个完全链上的 NFT,其作品本该是永久的。图像不是某个随时可能被
下架的 URL —— 它是一段 base64 字符串,在部署时写入合约存储,
之后永远无法改变。所以这个问题几乎是自己冒出来的:这样的作品
怎么会变?而且还是静默地变?
在我这个例子里,答案是:作品从来就不是一串固定的字符。它取决于
部署脚本在**执行部署的那台机器**上恰好从磁盘读到的字节 —— 而在
Windows 上,Git 会在脚本看到那些字节之前,悄悄地改写它们。
这事发生在一个小小的 Foundry 学习项目里,做的是一枚「心情」
NFT:一个 ERC-721,作品可以在笑脸 SVG 和哭脸 SVG 之间切换,
两者都编码在合约自身中。图像 URI 在部署时由 `img/` 目录里的源
文件拼出来,所以这些文件的逐字节内容就是作品本身。下面就是一条
坏掉的换行符,如何差点让这幅作品变成「因机器而异」。
## 我遇到的事:部署脚本编码的是「读到的字节」
部署脚本用几行就干完了全部活:
```solidity
string memory svgSmile = vm.readFile("img/smile.svg");
string memory svgSad = vm.readFile("img/sad.svg");
string memory imageUriSmile = svgToImageUri(svgSmile);
string memory imageUriSad = svgToImageUri(svgSad);
```
`vm.readFile` 返回一个字符串,`Base64.encode` 把这**一模一样的
字节**变成 `data:image/svg+xml;base64,...` URI,构造函数把两个
URI 永久存入存储。「链上」在这里是字面意思:部署机器上文件当时的
字节,如今就是合约数据 —— 永久地。部署时 SVG 哪怕差一个字节,
就是另一幅作品,在合约存续期内焊死不动。
我担心的这个差异来自 Git 的 `core.autocrlf`。Windows 上的 Git
安装通常会把人工作区里的文本文件改写成 CRLF 换行,即使仓库里存
的是 LF。SVG 是文本文件。CRLF 和 LF 是不同的字节,而 base64 对
不同字节的编码也不同。两行命令就能证明:
```bash
printf 'a\nb' | base64 # YQpi
printf 'a\r\nb' | base64 # YQ0KYg==
```
一个回车符,就改变了编码后的载荷。这类失败最讨厌的地方在于:
没有任何东西会告诉你。SVG 在任何一个编辑器里看起来都一模一样。
`git status` 依然干净,因为 Git 在比较文本时先做了换行归一化。
Foundry 也不在意 —— 它不解析 SVG,只是编码字节 —— 所以在任何
机器上都没有报错、没有警告。写进合约的作品,就这样静默地取决于
执行部署的是哪台机器。
## 修法:.gitattributes 里的一条规则
修法是一个文件、一条规则,而那段注释同样重要:
```gitattributes
# Force LF line endings for asset files read by forge scripts (vm.readFile)
# so the working tree always matches what's stored in git, regardless of
# core.autocrlf / Windows checkout behavior.
img/*.svg text eol=lf
```
为什么有效:`text` 告诉 Git 把这些文件当作文本并做归一化,所以
仓库里它们永远以 LF 存储。`eol=lf` 则把这些人路径的工作区检出
钉死在 LF 上,覆盖任何机器上的 `core.autocrlf` 设置。两个属性
合在一起意味着:在一台 `core.autocrlf=true` 的 Windows 机器上,
`img/*.svg` 依然以 LF 检出 —— 所以 `vm.readFile` 永远返回作者
提交时的那组字节,base64 URI 跨平台可复现。
说精确一点:这条规则管的是这些路径的检出,以及文件加入仓库时的
归一化。它并没有改写源 SVG —— 那些文件本来就是以 LF 提交的,
规则也不碰 blob 内容。它阻止的是规则落地之后、每一次检出时可能
发生的分歧。
同一个 commit 还修了第二件静默出错的东西:`foundry.toml` 里
`remappings` 的一个拼写错误。这条映射决定了
`@openzeppelin/contracts/...` 的导入如何解析到子模块,所以一个
错字就会让构建失败,而报错和我写的代码毫无关系:
```toml
remappings = ["@openzeppelin/contracts=lib/openzeppelin-contracts/contracts"]
```
## 紧挨着的两个坑
### 坑一:没有 fs_permissions,vm.readFile 拒绝执行
`vm.readFile` 是一个 *fs cheatcode* —— 除非
在 `foundry.toml` 里显式授权路径,否则 Foundry
不允许脚本触碰文件系统:
```toml
fs_permissions = [
{ access = "read", path = "./img/" },
{ access = "read", path = "./broadcast" },
]
```
对 `./img/` 的读权限是为了 SVG;`./broadcast` 是为了让铸造脚本
里的 DevOpsTools 助手能找到最近的部署日志。没有授权,部署会在
第一次读取时就失败 —— 这又是一种近乎静默的失败,因为报错指向
cheatcode,而不是你的代码。
### 坑二:DevOpsTools 需要 ffi = true
交互脚本会导入 `foundry-devops` 里的 DevOpsTools 来定位上一次
部署,而不是硬编码一个地址。这个导入需要启用 Foundry 的 `ffi`
cheatcode,所以配置里带着它,并附了一句说明注释:
```toml
ffi = true # For use of DevOpsTools import from lib/foundry-devops/src/DevOopsTools.sol
```
`ffi` 是货真价实的权限授予 —— 它让脚本可以运行任意 shell 命令
—— 所以那句注释是应得的;在开启它之前,值得先弄清楚究竟是哪个
导入需要它。
## 如果重来,我会怎么做
两个习惯能更早抓住这个问题,也能抓住下一条换行符回归:
1. **在测试里断言编码后的字节。** 集成测试已经会跑真正的部署
脚本,框架是现成的。加一个单元测试,断言
`vm.readFile("img/smile.svg")` —— 或者最终的图像 URI —— 等于
预期的 LF 编码 base64 字符串,那么 CRLF 回归就会让 `forge test`
大声失败,而不是悄悄把另一幅作品送上链。
2. **凡是脚本按字节读取的资源目录,都钉上 `eol=lf`。** 这个坑
不只属于 SVG。如果脚本要内嵌 JSON 元数据或任何其他文本资源,
同样的事情照样发生。经验法则:任何用 fs cheatcode 读的东西,
在脚本提交之前,先给它配一条 `.gitattributes` 规则。
## 结果
修复之后,base64 载荷在 Windows 检出和 Linux 检出上完全一致 ——
同一串字符串,在两台机器上各自算一遍,逐字节相等 —— 所以部署
出来的合约作品是可复现的,而不是因机器而异的。这正是链上作品的
全部意义所在;而它差一点就被一个文件、一条规则、一次 `printf`
就能证明的问题悄悄毁掉。
坦白说清范围:这是一个学习项目 —— 小而注释详尽的合约与部署
脚本,跑在本地 Anvil 节点和 Sepolia 测试网上,不是生产代码。
但「在我机器上是好的」本身就是一条 bug 报告,而这个修法背后
的纪律,正是生产部署需要的:精确知道你的工具链在发什么字节。
我平时做全栈网站开发 —— 前端、后端、自托管部署 —— 而这一类
字节级、跨平台的排障,正是真正上线软件时会遇到的事。如果你的
项目需要一个不只盯 diff、还盯着字节的开发者,跟我说说:
[WhatsApp](https://wa.me/60127972969) ·
[[email protected]](mailto:[email protected]?subject=Full-stack%20web%20development)
· [hoelee.com](https://hoelee.com)。