🔁 PAY_DIAMOND Migration Tool — REAL DATA MIGRATION ⚠️ MAINNET
Tool v1.1.0
⚠️ MOVES REAL MEMBER STATE FROM ONE PAY_DIAMOND TO ANOTHER.
This tool is for the situation where the OLD PAY_DIAMOND can no longer be
upgraded via diamondCut (e.g. its own diamondCut/Loupe/owner selectors were
destroyed) and a brand-new PAY_DIAMOND proxy has been deployed via
pay-diamond-deploy.html, reusing all existing facet contracts.
This tool does NOT deploy anything and does NOT run diamondCut — it only
reads member state off the OLD diamond (read-only, zero gas) and writes a
best-effort reconstruction of that state onto the NEW diamond via existing
admin functions, wallet-by-wallet, fully resumable. Go through the steps
below in order, at your own pace.
⚠️ Known data-migration gaps — read before migrating anyone
This is a best-effort reconstruction using only admin functions that
already exist in the deployed facets (grantMembership, adjustMemberSubscription,
setMemberLockedPrice, setMemberCountry, addCreditNoteWithExpiry,
addPaymentRecordsBatch, deactivateMember, plus the 5 new T190 setters —
setMemberReferralId, setCreditExpiry, setMemberLockedPlanPrice,
setMemberLockSnapshotMeta, setHasEverPurchasedPlan — see PaymentStorage.sol /
MembershipFacet.sol / PaymentAdminFacet.sol (v8.14.0) / PaymentCoreFacet.sol /
RefundFacet.sol in the holger-protocol repo). It is NOT a byte-for-byte
storage clone. The following fields/behaviors are honestly NOT fully
restorable with what exists today:
✓ referralId IS now preserved (T190, PaymentAdminFacet v8.14.0).setMemberReferralId(wallet, referralId) was added specifically to close
this gap — this tool now restores the OLD diamond's exact referralId onto the NEW
diamond right after grantMembership, instead of letting it get reassigned sequentially.
isGranted is always set to true after migration, even for members who
originally paid normally (isGranted=false on the OLD diamond). grantMembership is the
only admin entry point that creates member state on a diamond with no prior payment,
and it always sets isGranted=true. If any downstream logic treats isGranted specially,
this is a real behavior change post-migration. (Still an open gap — no setter exists
for this field; out of scope for the T190 setter batch.)
✓ creditExpiry now HAS an admin setter (setCreditExpiry(wallet,
newExpiry), T190, PaymentAdminFacet v8.14.0) — but it is still not auto-restorable
by this tool, because no view function on the OLD diamond exposes a member's real
creditExpiry value (getStats() never returned it, and no getter was ever added for it).
This tool still re-issues the OLD creditBalance as a single new credit note with NO
expiry (expiresAt=0) by default. If you know the original expiry from off-chain records,
call setCreditExpiry manually per-wallet after migrating that wallet.
currentPlanType (T128, used by proration on a member's NEXT plan upgrade) has
no admin setter in any of the 7 Payment facets — cannot be restored. A migrated
member's next upgrade will proration from a "no prior plan" base rather than their
real last-paid plan type. (Still an open gap; out of scope for the T190 setter batch.)
✓ memberLockedPlanPrices / memberLockSnapshotTaken / memberLockSnapshotTime
(per-plan multi-lock snapshot, T132) ARE now restorable (setMemberLockedPlanPrice
+ setMemberLockSnapshotMeta, T190, PaymentAdminFacet v8.14.0). This tool reads
every planId's locked price plus the snapshot-taken/time metadata straight off the OLD
diamond (via getMemberLockedPlanPrice/getMemberLockSnapshotInfo,
already-existing PaymentViewFacet getters) and replays the full map onto the NEW diamond,
not just the single legacy lockedPrice/lockedPlanType pair.
✓ hasEverPurchasedPlan[wallet][planId] (T171, gates renewalDurationSeconds
on repeat purchases) IS now restorable (setHasEverPurchasedPlan, T190,
PaymentAdminFacet v8.14.0). No getter exists for this mapping on the OLD diamond, so this
tool DERIVES it instead — from the wallet's already-scanned paymentHistory: any planType
seen in a Payment/ManualPayment record is marked true. This is a derivation, not a direct
on-chain read, but is grounded in exact historical fact from the same OLD diamond.
pendingRegistrations / pendingEmailHash (approval-gated registrations awaiting
Holger's approve/reject) are NOT migrated at all. Resolve every pending
registration on the OLD diamond (approve or reject) BEFORE migrating, or those
wallets' in-flight registrations will simply be lost. (Still an open gap; out of
scope for the T190 setter batch.)
Payment history timestamps/amounts ARE fully restorable — PaymentCoreFacet's
addPaymentRecordsBatch(address,PaymentRecord[]) exists specifically for this
(its own Solidity doc-comment says "Intended for one-time migration ... into
PAY_DIAMOND"). Every field of every historical PaymentRecord (timestamp, gross/net/vat
amounts, planType, country, entryType, txHash, superseded, learnEarnEligible) is
replayed exactly as read from the OLD diamond.
subscriptionExpiry IS restored exactly — via a grant-then-adjust two-step
(grantMembership with a near-future placeholder expiry, then
adjustMemberSubscription to land exactly on the OLD diamond's real expiry, including
lapsed/past expiries that grantMembership alone cannot accept since it requires a
future timestamp). See "how expiry restore works" note in Step 5.
Review this list per-wallet in Step 4 (DRY-RUN PREVIEW) before migrating anyone.
Progress backup Scan results + per-wallet migration status auto-save to this browser (localStorage key 'payDiamondMigrateV1'). Export/Import move progress across devices/browsers; Reset clears everything (asks to confirm). Large member lists may exceed localStorage quota — if so, use EXPORT after every scan/migrate batch as your real backup.
Step 1 — Connect wallet, verify OWNER + network
Strict OWNER gate, same as every other tool in this project — if the
connected wallet isn't exactly OWNER, every write button stays disabled. Read-only
Steps 2-4 work even before connecting, since they only ever call view functions.
Step 2 — Set OLD (source) and NEW (target) PAY_DIAMOND addresses
OLD is pre-filled with the real T119 mainnet address
(0x561ff332b76a7a79346a105cad4fddfb2f219ce8) — edit only if the diamond that needs
migrating away from is different. NEW must be a PAY_DIAMOND already deployed with all
10 facets wired (via pay-diamond-deploy.html) — this tool does not deploy or wire
anything itself.
OLD: NEW:
Step 3 — SCAN OLD DIAMOND (read-only, zero gas)
Calls getAllMemberWallets() then, for every wallet,
getStats(wallet) + getPayments(wallet) + getMemberCountry(wallet) —
all view functions, no wallet signature needed, no gas spent. Large member counts take a
while (one RPC round-trip per wallet per call); progress is shown live and saved after
each wallet so a page refresh mid-scan doesn't lose completed wallets.
Step 4 — DRY-RUN PREVIEW (read-only)
No transactions here — this just renders, per wallet, what Step 5 will
attempt to write, and whether the wallet already exists on the NEW diamond (in which case
it will be skipped automatically to avoid double-migration/overwrites).
Step 5 — MIGRATE (writes to NEW diamond — real transactions, real gas)
How expiry restore works: for each wallet, grantMembership(wallet, emailHash, now+90s)
is sent first (a near-future placeholder — grantMembership reverts if given a
non-future timestamp, so a lapsed/expired OLD member's real expiry can't be passed
directly). Immediately after, adjustMemberSubscription(wallet, delta) is sent
with delta = oldExpiry − (now+90s), which — per the real
adjustMemberSubscription logic in PaymentAdminFacet.sol — lands the NEW
diamond's subscriptionExpiry exactly on the OLD diamond's real value, whether that's
in the future (active member) or the past (lapsed member), regardless of the exact
number of seconds the transactions took to confirm.
Each wallet's migration is broken into independent sub-steps you can see fail/retry
individually: 1) grant+expiry, 2) locked price (only if lockedPrice>0),
3) country (only if set), 4) credit balance (only if creditBalance>0 —
see gap note above re: expiry), 5) payment history replay (chunked in batches of
20 records to stay under gas limits — only if any payments exist), 6) inactive flag
(only if the OLD member was inactive). "MIGRATE ALL" runs every pending wallet's full
sequence in order; each wallet can also be migrated/skipped individually from the table.