The silent page awaits the waking dawn.
HomepageArchive for homepage HTML/hashes, and ArticleArchive keyed by slug hash for article JSON/hashes. Web pages load from CDN/GitHub Pages, so speed is unchanged, while the blockchain provides a public, verifiable, immutable publishing record. Articles are normalized (LF line endings, gzip compressed, Base64 encoded) before on-chain storage to ensure deterministic builds across environments. The process includes CREATE2 for fixed contract addresses, scripts to avoid duplicate transactions, and a dual-build CI workflow to inject on-chain metadata. Key pitfalls addressed include testnet gas limits, RPC instability, dynamic content breaking hashes, and the distinction between archiving article text versus externally linked images.
A typical static blog solves the problem of 'how to publish', but does not fully solve the problem of 'how to prove': whether an article has been modified, when a particular version actually appeared, and whether the original content can still be verified after the site goes offline all depend on the site owner and the hosting platform.
The approach: Hexo generates the site, GitHub Actions handles the automated builds, Polygon PoS stores the homepage and article data, and GitHub Pages serves the normal browsing experience.
Final architecture
The whole publishing process can be summarized as:
There are two independent contracts here:
The pages are still loaded from a CDN or GitHub Pages, so loading speed is no different from a normal Hexo blog. The blockchain provides a public, verifiable publication record that cannot be silently rewritten.
Setting up the environment
Locally you need Node.js, pnpm, Git, and a Polygon wallet:
Install dependencies and verify Hexo:
Production publishing uses the Polygon mainnet, whose chain ID is 137, and the gas token is POL. I recommend creating a separate wallet used only for publishing rather than using the wallet that holds your main assets.
Design the on-chain article format first
An article can't just stuff Hexo's generated HTML into the contract as-is. The HTML mixes in theme structure, build metadata, and styling details, so a small theme adjustment would make every article produce a new version.
I ended up using the following normalized JSON:
content is not ordinary Markdown; it is the result of the following processing:
Determinism matters a lot. The same article must yield the same byte sequence whether it is built on Windows or Linux. Otherwise, every GitHub Actions run could mistake it for new content and pay gas for nothing.
The article content hash is not computed directly over the compressed Base64; instead, it computes keccak256 over the semantic JSON that contains the normalized Markdown. This way, the hash represents the article's content itself rather than the output details of a particular compressor version.
Image hashing
Local images can be read as real binary data and hashed, but remote images have a frequently overlooked problem: the build machine may not be able to download them reliably, and the remote may enforce hotlink protection, time out, or serve content that changes dynamically.
The current implementation computes an 'MD5 of the image URL string' for external images, which only identifies which address the article references; it cannot prove the content returned at that address, nor guarantee that the image will remain permanently available.
If you want strict, permanent image archiving, you can choose one of the following:
Therefore, 'the article's Markdown is on-chain' and 'all resources referenced by the article are permanently preserved' are two different things.
Designing the Solidity contract
The core structure of the article contract is not complicated:
When writing, it doesn't directly use the string slug; instead, it first computes:
The contract's publish function has to perform several checks:
I limit a single article to 24,576 bytes. The limit applies to the UTF-8 byte length of the final JSON, not the size of the Markdown file. gzip shrinks the body, but Base64 adds about a third back, so be sure to check the final payload before sending the transaction.
The contract stores two hashes at the same time:
What does 'permanent' actually mean?
The current structure overwrites publications[slugHash].payload on every update, so the contract's read interface can only return the latest version. Older versions still exist in the blockchain history, transaction calldata, and events, but they cannot be queried directly through the current mapping.
If you need every historical version to be directly readable from a contract method, it should be changed to:
The cost is continuously growing contract storage and higher gas. For a personal blog, keeping the latest readable version while relying on transaction history to prove older versions is a more practical trade-off.
Using CREATE2 to fix the contract address
CI can't depend on a deployment result saved locally, so I use CREATE2 to precompute the contract address:
initCode contains the contract bytecode and constructor arguments. As long as the deployer, salt, compiled output, and owner are exactly identical, the final address will be identical. The publish script first calls eth_getCode: if there is no code at the address, it deploys; if there is code, it reuses it directly.
The catch here is that changing the publishing wallet also changes the contract address, because owner is part of the constructor arguments. Upgrading the Solidity version, changing the optimizer settings, adjusting the contract code, or changing the salt will likewise produce a new address. Don't casually change these values without a migration plan.
How the publish script avoids duplicate transactions
The article publish script scans source/_posts and requires the filename to match the numeric slug:
The numeric slug must be a positive integer and must be unique. It serves both as part of the page's permanent link and as the logical primary key for the article on-chain. After publishing, don't move the article to a different slug, or the chain will treat it as a brand-new article.
For each article, the script first reads the on-chain record:
This means each CI run can scan all articles, but only newly added or actually modified articles will send a transaction. For unchanged articles, the script queries historical events by the publish block, recovers the transaction hash, and generates display metadata.
Before sending, it also runs estimateGas and reserves a 20% gas limit buffer:
The balance check must use the maxFeePerGas or gasPrice returned by the network. The first contract deployment is usually more expensive than later publishes, and the cost of storing a full HTML or JSON grows noticeably with the byte count.
Why the homepage needs separate normalization
The homepage stores the complete HTML generated by Hexo, but the bottom of the page includes the build hash and build time, and the page also displays the previous publish's block height and transaction hash. Hashing the HTML directly would create a loop:
The solution is to remove two dynamic regions before computing contentHash:
The replacement logic should require each region to appear exactly once. Silently ignoring template structure changes is dangerous, because a theme change could let a dynamic field slip back into the hash and cause you to pay on every build.
Note that the contract actually stores the complete HTML from that particular build; normalization is only used to compute the content hash and deduplicate. Changing only the build time will not overwrite the old on-chain HTML — that's the intended behavior.
GitHub Actions dual-build workflow
The workflow listens only to the main branch and grants contents: write permission:
The full steps are as follows:
The core commands can be written as:
.onchain/articles.json only serves the second build of the same workflow run, so it should be added to .gitignore. The truly trusted data source is still Polygon; the JSON file is only a cache for generating pages.
Secrets and permissions configuration
In the repository's Settings -> Secrets and variables -> Actions, you need to configure:
Type
Name
Purpose
Secret
`POLYGON_PRIVATE_KEY`
Private key of the publishing wallet
Variable
`POLYGON_RPC_URL`
Optional Polygon mainnet RPC
POLYGON_PRIVATE_KEY is the wallet's 32-byte private key, usually represented as 64 hexadecimal characters. The script accepts it with or without the 0x prefix, but you must not enter a wallet address, a mnemonic name, or an API key.
The private key must be stored in a Secret — not in repository files, an Actions Variable, build logs, or front-end JavaScript.GITHUB_TOKEN is provided automatically by GitHub Actions and doesn't need to be created manually.
For the publishing wallet, I recommend:
Verification before and after going on-chain
First verify the build and payload locally:
You can also simulate only the contract compilation, address calculation, and article size:
After publishing, you can read the article back from the contract and decompress it:
When verifying, don't just look at the text on the page. At minimum, confirm the following on Polygonscan:
Pitfalls I actually ran into
1. A Polygon testnet is not unlimited and free
The Amoy testnet is fine for validating the flow, but faucets usually limit how often and how much you can claim. The calldata and storage for a full HTML or article payload are expensive, and the first deployment plus first write can easily exceed the test tokens you can claim in a day.
When testing, shorten the payload first, verify 'deployment' and 'publishing' separately, and print the gas estimate before sending the transaction. When switching to mainnet, check the chain ID, RPC, block explorer URL, and gas token together — don't just swap the RPC URL.
2. Build time sends the homepage on-chain endlessly
As soon as the HTML contains the current time, Git commit, live block height, or a random value, the raw HTML hash becomes unstable. You must first define which fields represent content and which are only display metadata, then normalize strictly.
3. Unstable RPC causes intermittent CI failures
Public RPC endpoints may rate-limit, time out, or not support batch requests. The article publisher should disable unnecessary JSON-RPC batching, set an explicit network, and distinguish in its logs between 'RPC failure', 'insufficient balance', 'transaction reverted', and 'confirmation wait timeout'.
Don't resend unconditionally after a failure. First read the on-chain contentHash, because the previous transaction may have already been broadcast successfully and CI was just interrupted while waiting for the receipt.
4. External images can still disappear
The article body being permanent doesn't mean the OSS link is permanent. Storing only the MD5 of the URL also can't prove the content of the remote file. When you need complete evidence, migrate images to content-addressed storage and include the CID in the article's content hash.
References
Summary
Hexo provides a simple, reliable static content model; GitHub Actions turns building and publishing into a repeatable process; and Polygon provides a public timeline and verifiable bytes for every version. Combined, the site remains fast, readable, and easy to maintain, while also gaining content proof that doesn't depend on the credibility of the blog's own server.