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).
@@ -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)
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 46 KiB |
@@ -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';
|
||||
|
||||
@@ -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"> </span><span class="err">tokenURI → ipfs://… · art depends on a pin and a gateway</span></div>
|
||||
<div class="line"><span class="prompt"> </span><span class="cmd">Base64.encode(vm.readFile("img/smile.svg"))</span></div>
|
||||
<div class="line"><span class="prompt"> </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"> </span><span class="err">core.autocrlf rewrote the SVG with CRLF in the working tree</span></div>
|
||||
<div class="line"><span class="prompt"> </span><span class="cmd">printf 'a\\nb' | base64 ≠ printf 'a\\r\\nb' | base64</span></div>
|
||||
<div class="line"><span class="prompt"> </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"> </span><span class="err">InsufficientBalance() · the VRF mock had no subscription balance</span></div>
|
||||
<div class="line"><span class="prompt"> </span><span class="cmd">fundSubscription + addConsumer in setUp()</span></div>
|
||||
<div class="line"><span class="prompt"> </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"> </span><span class="err">page header rendered at 2.5pt · footer art off by 30–490pt</span></div>
|
||||
<div class="line"><span class="prompt"> </span><span class="cmd">per-sheet render + diff against the browser's own print</span></div>
|
||||
<div class="line"><span class="prompt"> </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"> </span><span class="err">row is already at version 4 · the write would win silently</span></div>
|
||||
<div class="line"><span class="prompt"> </span><span class="cmd">@Version → UPDATE … WHERE version = 3</span></div>
|
||||
<div class="line"><span class="prompt"> </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)。
|
||||