この章で3つの試作がつながります。
Transfer には memo 欄がない
まず、素朴にやってみたときの壁から。
所蔵機関のアドレス宛の入金を、ログから拾ってみます。「入金を待ち受ける」のではなく「ログを検索する」形になるのが要点で、サーバが落ちていた間の入金も後から同じコードで拾い直せます(ただし、どこまで遡れるかは RPC 提供者の制限に縛られます。この章の後半で実際に当たります)。
02 決済の検知 — Polygon Amoy
受取先 0xA87A6c519560cd33FAC1bfe469a059aE524767BA
トークン JPY Coin (JPYC) / decimals 18
探索範囲 ブロック 45,296,948 〜 45,316,948 (20,000 ブロック)
閲覧料 500 JPYC 以上を受理
1 件の入金
100,000 JPYC 受理
送信元 0x192facc5b2c6fdae484b1e76f542e983e418f26e
ブロック 45,315,462 / 1,487 確認
検知はできました。ただし、ここで詰まります。
Transferが運ぶのは (送信元, 宛先, 金額) の3つだけで、memo 欄がありません。 「どの資料の閲覧料か」は、この情報からは決まりません。
銀行振込に振込人名義があるのとは違い、ERC-20 の送金には自由記述の欄が一切ないのです。
解き方は主に3つあります。
| 方法 | ガス | 表現力 | |
|---|---|---|---|
| a | 資料ごとに受取アドレスを分ける | 不要 | 低(金額と宛先だけ) |
| b | 決済用のコントラクトを噛ませる | 配置に必要 | 高(任意の値を載せられる) |
| c | 金額の端数で識別する | 不要 | 低・衝突しうる |
b を選びました。 理由は前章までとの接続にあります。
30行のコントラクト
pay(bytes32 itemId, uint256 amount) という関数を1つ持つだけのコントラクトを書きました。
contract AccessGateway {
IERC20 public immutable token;
address public immutable treasury;
event AccessPaid(address indexed payer, bytes32 indexed itemId, uint256 amount);
function pay(bytes32 itemId, uint256 amount) external {
if (amount == 0) revert ZeroAmount();
if (itemId == bytes32(0)) revert ZeroItemId();
if (!token.transferFrom(msg.sender, treasury, amount)) revert TransferFailed();
emit AccessPaid(msg.sender, itemId, amount);
}
}
itemId には、第5章で署名したマニフェストの版ハッシュを入れます。
942f87616e3558da30c5b6cbd867c491b9b43d8d86af860dc661f535fca64d68
SHA-256 なので 32 バイト、bytes32 にちょうど収まります。「支払われた」ではなく「この版に対して支払われた」でないと、後から資料を差し替えられたときに記録の意味が消えます。
意図的にやっていないこと
資金を預かりません。 受け取った JPYC はその場で treasury へ素通しします。残高を持たないので引き出し関数が要らず、引き出し関数が無ければ、溜めた資金を持ち出される経路も生まれません(誤って直接送られたトークンは回収できない、という裏返しはあります)。機関が運用するうえで、この性質は効きます。
価格を持ちません。 いくら以上で受理するかは運用方針であって、変わるたびにガスを払い、しかも消せない台帳に刻むものではありません。金額の判定はログを読む側が行います。
許諾そのものを発行しません。 このコントラクトは「支払われた」ことだけを証言します。それを「閲覧してよい」に変換するのは機関の判断で、証明書の発行はチェーンの外で行います。
indexed を付けた項目は検索できるようになります。payer と itemId に付けたのは、「誰が払ったか」「どの資料に払われたか」で絞り込みたいからです。amount には付けていません。金額で検索することは、まずないからです。
ローカルにチェーンを複製する
反復の多い作業を公開テストネットでやると、Faucet の残高、レート制限、確認待ちに時間を取られます。そこで Amoy をローカルに複製しました。
anvil --fork-url <Amoy の RPC> --chain-id 80002
これは git のフォークでもハードフォークでもなく、チェーンの状態の複製です。全データをダウンロードするのではなく、遅延読み込みで動きます。
自分のコード ──「JPYC の残高を教えて」──▶ ローカルの anvil
│
手元に無い ├──「その1スロットだけ」──▶ Amoy
│◀── 値が返る。以後キャッシュ
│
書き込みは ─────────┘ ローカルにだけ溜まる
なお、フォークにはエクスプローラがありません。 anvil はチェーンだけを提供するので、amoy.polygonscan.com のような画面で確認することはできません。残高やログは RPC を直接叩いて読みます(この試作のスクリプトがやっているのはそれです)。公開テストネットに出す利点の1つは、この「人が見られる画面」が付いてくることです。
本物の JPYC のバイトコードが、本物のアドレスにそのまま乗ります。 モックではありません。だから読者役に JPYC を配るときも、mint の権限を偽装する必要がなく、Amoy 上で実際に JPYC を保持している Faucet のコントラクトになりすまして送らせれば済みます。
一方 POL は anvil_setBalance で直接書き込みます。台帳が直接持つ残高なので、コントラクトを通さずに設定できる。前章で見た「2種類の残高」の違いが、ここで操作方法の違いとして現れます。
フォークで踏んだ3つの罠
1. 別プロジェクトの anvil が同じポートを使っていた。
✔ anvil 起動 ブロック 18 時点の Amoy を複製
Amoy は 4,531万ブロックあるので、この行自体が異常を語っていました。実際には chainId 31337(anvil の既定値)の空のチェーンに繋がっていて、コントラクトもそちらに配置されていました。
フォークの成否は、「本物のコードがそこにあるか」で判定するのが素直です。ブロック番号や chainId は取り違えが起きえますが、特定のアドレスに特定のコードが載っていることは、フォークが成立していないと再現できません。
CODE_LEN=$(cast code "$JPYC" --rpc-url "$LOCAL" | wc -c)
2. 公開 RPC でフォークすると数分で落ちる。
The application panicked (crashed).
historical state f862d65d…f4c is not available
フォークは必要な状態をその都度フォーク元に問い合わせ続けます。ところが Polygon の bor(go-ethereum のフォーク)は、既定で直近128ブロック分の状態しか保持しません(ソース上の定数 TriesInMemory = 128)。Amoy は1秒に1ブロックなので、約2分でピン留めした時点の状態が消え、次のトランザクションで落ちます。
なお、この窓の広さは提供元によります。今回使った公開 RPC はまさに128ブロックちょうどで切れましたが、Alchemy や Infura は無料枠でも Amoy のアーカイブデータを提供すると明記しています。「公開 RPC だから浅い」のではなく「アーカイブ設定でないノードだから浅い」というのが正確です。
「フォークすれば独立して動く」と思い込んでいましたが、実際には上流に依存し続けていて、上流の保持期間に寿命が縛られます。ここで初めて archive 対応の RPC が必要になりました。
3. フォーク時点より前のログを検索すると、上流の制限に当たる。
Under the Free tier plan, you can make eth_getLogs requests with up to a 10 block range.
探しているログはフォーク後にローカルで作られたブロックにしかないので、forkedAtBlock を検索範囲の下限にすることで解決しました。
支払ってみる
準備が整ったので、500 JPYC を支払います。
03 支払い — ローカルフォーク (anvil)
利用者 0x70997970C51812dc3A010C7d01b50e0d17dc79C8
ゲートウェイ 0x1b2d4802B57832b65f3E56b2299a1b5B3DC95813
受取先 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC
対象資料 0x942f87616e3558da30c5b6cbd867c491b9b43d8d86af860dc661f535fca64d68
↑ 第5章で署名したマニフェストの版ハッシュ
金額 500 JPYC (500000000000000000000 最小単位)
① approve — ゲートウェイに引き落としを許可する
✔ 0x94bc2b6028b7746a4eca8c192eaa1767ec860e515cd2dd9c5bc40704cba18fc6 gas 57,778
② pay — 資料 ID を添えて支払う
✔ 0x46f2762ca2de5cc99c672c954bca95150d8167f8b67ebf2f8f20547e02ebeb97 gas 71,285
このトランザクションが残したログ 2 件
Transfer ← JPYC。誰にいくら、しか分からない
AccessPaid ← 0x1b2d4802B57832b65f3E56b2299a1b5B3DC95813
支払者 0x70997970c51812dc3a010c7d01b50e0d17dc79c8
資料 0x942f87616e3558da30c5b6cbd867c491b9b43d8d86af860dc661f535fca64d68
金額 500 JPYC
残高の動き
利用者 50,000.01 → 49,500.01
受取先 0 → 500
ゲートウェイ 0 ← 素通しなので溜まらない
2段構えになる理由が approve です。JPYC のコントラクトはゲートウェイの存在を知りません。「このアドレスに、この額まで引き落とさせてよい」を利用者が先に宣言する必要があります。ERC-20 の送金を第三者に代行させるときは、基本的にこの2段になります(署名で承認を渡す拡張を備えたトークンなら1段にできますが、標準の ERC-20 の範囲ではこの形です)。
ログが2件なのが要点です。Transfer だけを見ていた段階では「誰から誰にいくら」しか分かりませんでした。AccessPaid が加わったことで、どの版に対する支払いかが同じ記録の中に入りました。
なお、利用者の残高が 50,000.01 と端数になっているのは、anvil の既定アカウントが本物の Amoy 上で既に 0.01 JPYC を持っていたからです。既定アカウントの鍵は公開されているので、他の誰かが実際に使っています。公開チェーンでこの鍵を使ってはいけない理由が、こういう形で見えます。
アドレスを証明書の名宛人にする
支払ったのはチェーン上のアドレスでしかありません。これを証明書の名宛人にするには、DID に直す必要があります。
did:pkh がそれを担います。
did:pkh:eip155:80002:0x70997970c51812dc3a010c7d01b50e0d17dc79c8
~~~~~~ ~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
名前空間 チェーン アドレス
第3章の3方式に、4つ目として並べられます。
| 解決に必要なもの | |
|---|---|
did:key | なし(識別子そのものが鍵) |
did:web | ドメインの所有 + TLS 証明書 |
did:webvh | 同上 + 公開された追記ログ |
did:pkh | なし(アドレスが鍵の指紋) |
did:key と同じく台帳もレジストリも要りません。違いは、その識別子がチェーン上で実際に活動していることです。did:key は誰の鍵か誰も保証しませんが、did:pkh のアドレスには取引の履歴が付いています。
発行する
AccessPaid を読んで、証明書を発行します。署名鍵と発行者 DID は第2章のものをそのまま使います。依存パッケージがないので、隣のディレクトリから直接読み込めました。
04 利用許諾の発行 — ローカルフォーク (anvil)
ゲートウェイ 0x1b2d4802B57832b65f3E56b2299a1b5B3DC95813
閲覧料 500 JPYC 以上
支払い 1 件
発行者 (第2章の鍵をそのまま使用)
DID did:web:nakamura196.github.io:did-log
鍵 key-2
支払い 500 JPYC 受理
支払者 0x70997970c51812dc3a010c7d01b50e0d17dc79c8
名宛人DID did:pkh:eip155:80002:0x70997970c51812dc3a010c7d01b50e0d17dc79c8
対象資料 0x942f87616e3558da30c5b6cbd867c491b9b43d8d86af860dc661f535fca64d68
✔ 発行 out/credentials/access-46f2762ca2de.jwt
根拠 tx 0x46f2762ca2de5cc9… / block 45,560,413
発行された証明書の中身です。
{
"iss": "did:web:nakamura196.github.io:did-log",
"sub": "did:pkh:eip155:80002:0x70997970c51812dc3a010c7d01b50e0d17dc79c8",
"vc": {
"type": ["VerifiableCredential", "AccessCredential"],
"issuanceDate": "2026-10-01T00:00:00Z",
"credentialSubject": {
"id": "did:pkh:eip155:80002:0x7099…79c8",
"grantedFor": {
"manifest": "https://nakamura196.github.io/did-log/iiif/genji/manifest.json",
"versionDigest": "0x942f87616e3558da30c5b6cbd867c491b9b43d8d86af860dc661f535fca64d68",
"digestAlgorithm": "SHA-256"
},
"evidence": {
"type": "BlockchainPayment",
"chainId": 80002,
"contract": "0x1b2d4802B57832b65f3E56b2299a1b5B3DC95813",
"transactionHash": "0x46f2762ca2de5cc99c672c954bca95150d8167f8b67ebf2f8f20547e02ebeb97",
"blockNumber": 45560413,
"amount": "500000000000000000000",
"token": "0xE7C3D8C9a439feDe00D2600032D5dB0Be71C3c29"
}
}
}
}
3つの試作が1本につながっています。
issは第2章の例示大学 DID(鍵もそのまま)versionDigestは第5章で署名したマニフェストの版ハッシュevidenceはこの章のチェーン上の支払い
何が新しくなったか
第2章の証明書が示せたのは、「大学がそう言っている」までです。第4章で見た通り、その大学は自分のログを作り直せます。
この証明書には evidence が入っています。「なぜ発行したのか」を第三者が独立に確かめられる形になります。 大学が自分の記録を作り直しても、支払いの事実は大学の管理下にないので消えません。
第4章で必要だと分かった witness を、チェーンが務めている形です。
ただし、この実行結果では確かめられない
正直に書いておかなければならないことがあります。この章の支払いはローカルのフォークの上で起きています。
03 支払い — ローカルフォーク (anvil)
04 利用許諾の発行 — ローカルフォーク (anvil)
つまり、証明書に入っている 0x46f2762c… を amoy.polygonscan.com で引いても出てきません。あのトランザクションは私の手元のマシンにしか存在しないからです。chainId に 80002 と入っているのは、フォークを起こすときに Amoy と同じ番号を指定したためで、公開テストネットに書かれたという意味ではありません。
そして、フォークは私が何度でも作り直せます。この章で実行したことは、「機関が自分の過去を作り直せない」ことの実証にはなっていません。実証されているのは、そこに至る手続きが動くことだけです。
証拠として成立させるには、同じコントラクトを Amoy に配置して同じスクリプトを流す必要があります。コードは1行も変わりません(第9章で見た通り、変わるのは接続先だけです)。ここでフォークを使ったのは、ガス代の調達と公開 RPC の不安定さを説明から切り離すためで、その判断の代償がこれです。
第9章の Amoy 上のトランザクション(block 45,315,462)は誰でも引けますが、あれは Faucet から JPYC を受け取った記録であって、AccessPaid ではありません。この本の中で、支払いを根拠にした証明書が公開の場に立っている状態は、まだ作れていません。
もうひとつの形 — 金銭を伴わない記録
同じ機構は、支払い以外でも成り立ちます。もうひとつのコントラクト ApprovalRegistry は、機関が申請を判断したことを記録します。
AccessGateway | ApprovalRegistry | |
|---|---|---|
| 記録する事実 | 閲覧料を支払った | 機関が申請を判断した |
| 呼ぶ人 | 利用者(誰でも) | 登録者(機関)だけ |
| 資金 | JPYC が動く | 動かない |
| 乱発の抑止 | 支払いのコスト | アクセス制御 |
支払いを伴う側は誰でも呼べるままにしてあります。 支払いそのものがコストなので、書き込みの乱発が自動的に抑えられるからです。金銭が動かない側には同じ抑止が効かないので、登録者を絞らないと「承認された」と誰でも主張できてしまいます。
ApprovalRegistry には、もうひとつ設計上の判断が入っています。現在の状態を持ちません。
enum Decision { Approved, Denied, Revoked }
function decide(bytes32 requestId, address applicant, bytes32 itemId, Decision decision)
external onlyRegistrar
{
emit RequestDecided(requestId, applicant, itemId, decision);
}
「いまこの申請は承認されているか」という変数を持たせると、その1個の値を書き換えるだけで過去が見えなくなります。撤回は Revoked の追記で表し、最新の判断はログを時系列で読む側が決めます。
そして却下も記録できるようにしてあります。承認だけを残すと、機関の判断の履歴が偏って見えるからです。ただしこれは、次章で扱うプライバシーの問題を強めます。