2026-09-13 ウォレット状態表示の判定元と表示方針(Issue #91)【決定案】
状態: 決定案。Issue #91 での合意後に「決定案」の表記を外し、実装Issueへ分ける。
- 課題:
/setupの「YOUR CURRENT STATUS」と/passportの「WALLET STATUS」は、PR #105 以降も HENKAKU 保有と Allowlist 登録を「準備中」と表示している。Issue #91 が求める「接続中のアドレスの状態を、別画面や外部ツールを探さずに把握できる」体験には、判定元・表示場所・取得失敗時の扱いの決定が必要だった。
前提として確認した事実(2026-09-13、Polygon mainnet)
- HENKAKU トークン(
0x0cc91a5FFC2E9370eC565Ab42ECE33bbC08C11a2)の実体は henkaku-center/henkaku-v2 のHenkakuToken.sol。Allowlist は同じコントラクト内のmapping(address => bool) private whitelistで、_beforeTokenTransferが送信者・受信者の両方に登録を要求する。つまり Allowlist の正本はこのコントラクトのストレージであり、別の Allowlist コントラクトは存在しない。 unlockフラグ(storage slot 10)はfalse。Allowlist による転送制限は現在も有効。- 読み取り関数
isAllowed(address)はonlyOwnerの view 関数で、通常のeth_callはOwnable: caller is not the ownerで revert する。ただしeth_callは署名を伴わないため、fromにowner()(0x5983c3bd118d29606466213d81ea478501b31b1e)を指定すれば誰でも読める。 - 代替経路として、
whitelistの storage slot(slot 7、keccak256(abi.encode(address, 7)))をeth_getStorageAtで直接読める。 - 上記2経路を保有上位5アドレス(すべて
true)と、owner・0x…dEaD・gateKeeper(すべてfalse)で突き合わせ、公開RPC2系統(polygon.drpc.org/polygon-bor-rpc.publicnode.com)で一致を確認した。 dev(定期更新用EOA)は zero address。gateKeeper(multisig)は0x3F1D35bF5f182D7b4ef95a3f298f059BC33d2020で、Allowlist 未登録・残高0。- viem の polygon チェーン既定RPCは
https://polygon.drpc.org(node_modules/viemで確認)。https://polygon-rpc.comは「API key disabled」で応答せず、rpc.ankr.comはAPIキー必須。lib/wagmi.tsのhttp()は既定RPCを使うため、現状のままでもeth_callは通るが、無料枠の制限(例:eth_getLogsは10,000ブロックまで)がある。
決定案
1. 表示場所
/setup の「YOUR CURRENT STATUS」と /passport の「WALLET STATUS」の両方(既存の PortalWalletStatus)に、オンチェーンの読み取り結果を表示する。/passport の申請記録(allowlistStatus / distributionStatus)はそのまま残し、オンチェーン欄と並べて見せる。新しい画面や共通ステータス領域は作らない。
理由: 表示枠は PR #105 で既に用意されており、Issue #91 の「接続後にまとめて確認」は /setup で、「申請記録との関係」は /passport で満たせる。
2. HENKAKU 保有の判定
balanceOf(address) > 0を「保有している」とする。- 残高も表示する。
formatUnitsで18桁を読み、1以上は整数に切り捨てて千区切りで出す(例「1,234 HENKAKU」)。0より大きく1未満の残高は「1未満」と表示し、「0」とは区別する(切り捨てで少額の保有が未保有に見えないようにする)。0 と「読めていない」を見分けやすくするため、数値の横に判定語(保有している / 保有していない)を置く。
| 残高 | 表示 | 判定 |
|---|---|---|
| 0 | 「0 HENKAKU」 | 保有していない |
| 0より大きく1未満 | 「1未満 HENKAKU」 | 保有している |
| 1以上 | 整数に切り捨て(例「1,234 HENKAKU」) | 保有している |
理由: 保有の有無だけを出すと、配布直後の確認(10 HENKAKU が届いたか)ができない。残高は公開情報で、追加コストなく同じ呼び出しで取れる。
3. Allowlist の判定元と読み方
- 正本はオンチェーン(コントラクトの
whitelist)。アプリ内のapplications.allowlist_statusは「運営が実行を記録したか」を表す業務記録であり、登録の証明ではない。 - 読み取りは
isAllowed(address)をfrom = owner()でeth_callする。owner()は固定値にせず先に読み(実質固定値なので長めにキャッシュしてよい)、その値をisAllowedのaccount(from)に指定する。 isAllowedは Multicall3 を経由しない直接呼び出しにする。 Multicall3 のaggregate3に乗ると、トークン契約から見たmsg.senderは Multicall3 になり、onlyOwnerのisAllowedは revert する。外側のfromに owner を指定しても解消せず、allowFailure: trueでも個別失敗として返るだけで直接読み取りへは戻らない。そのため wagmi のuseReadContracts(viem のmulticall)は使わず、関数ごとのuseReadContract(viemreadContract)にaccount = ownerを渡す。viem 2.56 のcallはfromが付いた要求を Multicall3 にまとめない(shouldPerformMulticall)ため、この指定で直接のeth_callになる。balanceOf/ownerはfromを持たないので、既定の Multicall3 集約に乗ってよい。- storage 直読み(slot 7)は、
isAllowedが読めなくなった場合の代替として決定記録に残すが、初版では実装しない。
理由: 転送可否を決めているのはコントラクトのストレージだけで、アプリ記録が「追加済み」でも実際に未登録なら送付は失敗する。from 指定の eth_call は署名不要・読み取り専用で、秘密情報を扱わない。
4. オンチェーンと申請記録が食い違ったとき
2つの値を1つに統合せず、別々の行として表示する。補足文は確認できた状態をそのまま伝え、承認を確約するように読める表現は避ける。組み合わせごとの補足文(申請記録は allowlistStatus と reviewStatus の両方を見る):
| オンチェーン | 申請記録(Allowlist) | 申請記録(審査) | 表示する補足 |
|---|---|---|---|
| 追加済み | pending / failed | 任意 | 「Allowlistには登録済みです。申請記録は未更新です」 |
| 追加済み | added | 任意 | 補足なし |
| 未追加 | added | 任意 | 「オンチェーンで確認できません。運営に連絡してください」+ 記録済みの tx hash へのリンク |
| 未追加 | pending | pending / needs_info(審査中) | 「申請が承認されると、運営が追加します」 |
| 未追加 | pending | approved | 「運営による追加を待っています」 |
| 未追加 | pending | rejected | 補足なし(申請記録の行に「見送りになりました」が出る) |
| 未追加 | failed | 任意 | 補足なし(申請記録の行に「運営が対応中です」が出る) |
| 未追加 | 申請なし | — | 補足なし |
| 取得失敗 | 任意 | 任意 | オンチェーン欄だけ「取得できませんでした」+ 再試行。申請記録は通常どおり |
理由: 監査ログ(application_events)が唯一の裏付けという既存方針(2026-08-09-repeated-failure.md)を崩さず、利用者には実態を隠さない。「運営に連絡してください」の連絡先は、既存の /passport の「運営からの案内に沿ってご対応ください」と同じく特定のチャンネルを書かない。連絡経路は #48 の業務フローで決まった時点で文言に反映する。
5. 取得タイミングと失敗時の見せ方
- 取得は接続中のアドレスに対して行い、サインインは要求しない(公開情報のため)。未接続なら「ウォレットを接続すると表示します」。
- wagmi の
useReadContractを関数ごとに使い、クエリキーにアドレスを含める(isAllowedの直接呼び出しは 3. を参照)。アカウント切替時は新しいアドレスの取得中表示に切り替わり、前のアドレスの値を残さない。チェーン切替では再取得しない(読み取りは常に Polygon の transport で行うため。表示に「Polygon上の状態」と明記)。 staleTime30秒、ウィンドウフォーカスでの自動再取得は無効、手動の「再取得」ボタンを置く。配布直後の確認に使えれば十分で、公開RPCへの呼び出しを増やさない。- 状態は 取得中 / 取得できませんでした / 保有していない・未追加 / 保有している・追加済み の4つを別の表示にし、
—や空欄を「未保有・未追加」の意味で使わない。 - RPC は任意の環境変数
NEXT_PUBLIC_POLYGON_RPC_URLで差し替え可能にし、未設定なら viem の既定を使う。.env.exampleに「未設定でも動く」と明記する。
6. プライバシー
ブラウザから RPC 事業者へ「接続中のウォレットアドレス」と「閲覧者のIPアドレス」が送られる。ウォレット拡張自身も同様の通信を行うため新しい種類の情報ではないが、docs/privacy-policy.md の「外部サービスと第三者提供」に、公開RPCへ問い合わせる事実と送信内容を一文追加する。
実装Issueへの分割案
lib/domain/walletStatus.ts: 読み取り結果(残高・Allowlist)と申請記録(allowlistStatus/reviewStatus)から表示状態(4状態 + 残高表示 + 補足文)を返す純粋関数と、その単体テスト(DB・ネットワーク不要)lib/henkakuToken.tsに ABI(balanceOf/owner/isAllowed)を追加し、PortalWalletStatusを関数ごとのuseReadContractで接続(isAllowedはaccount = ownerの直接呼び出し)。wagmi をモックした単体テストで4状態と、アドレス切替時に前の値が残らないことを検証する。加えて、実際の読み取り経路でトークン契約への呼び出し元が owner になり、Multicall3 を経由しないことを、wagmi の返り値ではなく transport に届く JSON-RPC(eth_callのto/from)で検証する。wagmi の返り値だけをモックしたテストでは、この取り違えを検出できない/passportの申請記録との並列表示と補足文.env.example・docs/guide/portal-app.mdの「準備中の範囲」・docs/privacy-policy.mdの更新
手動検証は、上記「前提として確認した事実」で使った保有上位アドレスと未登録アドレスを watch-only で接続するか、テスト用ウォレットで行う。本番RPCへの呼び出しはCIに含めない。
採らなかった案
- アプリ内の申請記録を Allowlist の正本にする: 実行を記録するだけで登録を保証しないため、利用者に誤った「追加済み」を見せうる。
- サーバー側(Route Handler)で RPC を代理する: 未認証エンドポイントが増え、#50 のレート制限の対象が広がる。読み取りは公開情報で、ブラウザから直接読んでも秘密情報を扱わない。
- Transfer イベントの集計で保有を判定する: 公開RPCの
eth_getLogs制限に当たり、balanceOfで足りる。