Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Orchestrates a PRODUCTION multisig rollout end-to-end — drives a facet/periphery deployment (by delegating to the `deploy-contract` skill) or a whitelist sync across many chains, then captures the Safe proposals, drafts a PR with the deployed addresses, hands hardware-wallet signing to the user, verifies signatures in MongoDB, and posts the
.claude/skills/lifinance-multisig-rollout/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-05 | ✗→✓ | ▲ Improved | 255% | 0% |
| case-06 | ✗→✓ | ▲ Improved | 152% | 0% |
| case-07 | ✗→✓ | ▲ Improved | 251% | 0% |
| case-08 | ✗→✓ | ▲ Improved | 234% | 0% |
| case-09 | ✗→✓ | ▲ Improved | 288% | 0% |
Drives the production rollout lifecycle in two modes:
deploy-contract skill; this skill owns the proposal lifecycle around it.config/whitelist.json onto the affected chains' diamonds, proposing the changes to each chain's Safe.Both modes converge on the same tail: capture proposals → (deploy mode only) draft PR with addresses → hand off hardware-wallet signing → verify signatures in MongoDB → post the #dev-sc-multisig-proposals Slack thread.
PolymerCCTP add-on: a PolymerCCTPFacet rollout that adds a chain or CCTP corridor also has to propagate its chainId→CCTP-domain mapping to every already-live chain — extra Safe proposals that ride through the same tail. See Phase 3b.
Signing model (Safe threshold is 3): a freshly created proposal already carries one signature. The user running this skill adds a second via confirm-safe-tx.ts. The Slack thread then recruits the remaining signer(s) to reach the threshold, the last of whom executes. So the verification gate before posting is "the runner has signed" — signatureCount >= 2 — deliberately short of the threshold; recruiting the rest is the whole point of the Slack ask.
See also: the wallet-rotation orchestrators rotate-deployer-wallet and offboard-sc-dev drive this skill to propose Safe owner / CANCELLER-role swaps.
confirm-safe-tx.ts yourself. Signing uses the user's hardware wallet; only the human can do it. Your job ends at giving the exact command.deployContractToNetworks.sh via deploy-contract, and syncWhitelistToNetworks.sh) require --production on the CLI and PRODUCTION=true in .env. Do not edit .env to satisfy this — if it mismatches, stop and tell the user.SEND_PROPOSALS_DIRECTLY_TO_DIAMOND must NOT be true (it would bypass the Safe). Abort if set.Proceed optimistically — do not pre-gate on tunnel or signing. Assume the human has the lifi-connect tunnel up (per docs/Setup-agents.md). Do not ask "how should we run this given I can't open the tunnel / sign" — just execute. The MongoDB scripts fail fast (list-pending-proposals.ts exits 2) if the tunnel is actually down, and signing is handed off in Phase 6 when it's actually needed. The only up-front gate is confirming the resolved plan (contract/version/networks) in Phase 2, since it costs gas across many chains.
Run from the repo root. Check and report (don't fix silently) the lifecycle prerequisites:
.env exists, PRODUCTION=true, SEND_PROPOSALS_DIRECTLY_TO_DIAMOND not true, MAX_CONCURRENT_JOBS set.gh auth status OK; Slack MCP connected (needed in Phase 8 — warn early if missing, posting falls to the user).list-pending-proposals.ts exits 2 with a clear message when the tunnel is down; relay that to the user when it happens.In deploy mode, deploy-contract re-checks the deploy-side prerequisites (Foundry, deployer balances, the .env/--production agreement) before touching any network — don't duplicate that here.
Do not invoke deploy-contract yet. Phase 1 is target-resolution only; the deploy runs in Phase 2 after the user confirms the plan.
If the user named explicit chains, use those. Otherwise discover every chain where the contract is already live:
bashfor F in deployments/*.diamond.json; do NET=$(basename "$F" .diamond.json) V=$(jq -r --arg N "<Contract>" '(.LiFiDiamond.Facets // {}) | to_entries[] | select(.value.Name == $N) | .value.Version' "$F" 2>/dev/null | head -1) [ -n "$V" ] && echo "$NET $V" done
Repo version: grep -m1 "@custom:version" src/Facets/<Contract>.sol (or src/Periphery/...). Report old → new per network. Check if the contract is diamond-called (needs a second allowlist proposal per network):
bashjq -e --arg N "<Contract>" '.whitelistPeripheryFunctions | has($N)' config/global.json
If the user didn't supply a whitelist PR (number or URL), ask for it — don't guess from recent merges or the working tree. The PR defines exactly which whitelist change is being rolled out and is the link the Slack post references.
The input PR must be merged to main (whitelist changes are main-only by policy; the sync reads the local file). If it's open, stop and point the user at the merge first. Then, on up-to-date main, derive affected networks from the PR's whitelist diff (verified recipe):
bashMERGE=$(gh pr view <N> --repo lifinance/contracts --json mergeCommit --jq '.mergeCommit.oid') PROG='[ (.DEXS[]? | .contracts | to_entries[] | {k: .key, v: .value}), (.PERIPHERY | to_entries[]? | {k: .key, v: .value}) ] | group_by(.k) | map({key: .[0].k, value: (map(.v) | tojson)}) | from_entries' git show "${MERGE}~1:config/whitelist.json" | jq -S "$PROG" > /tmp/wl-base.json git show "${MERGE}:config/whitelist.json" | jq -S "$PROG" > /tmp/wl-head.json jq -rn --slurpfile A /tmp/wl-base.json --slurpfile B /tmp/wl-head.json \ '[($A[0] + $B[0]) | keys[]] | unique | map(select($A[0][.] != $B[0][.])) | .[]'
The sync itself is on-chain-diff-driven, so a too-wide network list is harmless (extra networks no-op) — but keep the list tight so the run stays fast and the Slack post stays truthful.
Present: mode, contract + version (or PR + summary), full network list, and what will be created (one timelock-wrapped Safe proposal per chain — two for a diamond-called periphery: registration + whitelist). Wait for explicit go-ahead before proceeding.
After confirmation, in deploy mode invoke deploy-contract:
text/deploy-contract <Contract> <network...> --production
It deploys (CREATE3), verifies on the explorer, and registers in the diamond (diamondCut for facets, diamondUpdatePeriphery for periphery), plus the allowlist sync for diamond-called periphery. It hands back a per-network table: contract, version, network, chainId, address, registration type, proposal created, verification status.
Carry forward: deployed addresses (for the PR table), succeeded/failed networks, whether the allowlist sync ran. Files changed on disk — deployments/<net>.json, plus config/whitelist.json / config/whitelist.staging.json when the allowlist synced — are committed in Phase 5.
Set the interaction model up front so the user knows the shape: tell them this rollout is semi-automated — at one point it will pause and ask them to sign the proposals with their hardware wallet, after which they come back to this chat and the skill finishes the rest (verify signatures + post Slack). Knowing the pause is coming is what stops them from completing those final steps by hand.
deploy mode already executed via deploy-contract in Phase 2. For whitelist mode, run in the background, monitor output, report per-network results:
bash./script/tasks/syncWhitelistToNetworks.sh <network...> --production
Ends with a per-network summary and exits 1 if any network failed. Failures don't block survivors: continue with the succeeded networks, report the failed ones, and offer to retry them individually. Each proposal is created already carrying one signature (signatureCount: 1). A production sync automatically re-syncs staging on the same networks afterwards (staging sends directly, no proposals) — expected, not an error.
Facet removals are no longer proposed by hand here. When DRAIN_PARKED_TASKS=true, every production facet cut's runPropose call automatically folds that network's parked facet-removal tasks (the deferred diamond-cleanup queue) into that network's facet-cut proposal — one extra diamondCut Remove element per parked facet, appended to the same timelock scheduleBatch. This adds no extra proposal: the removals ride the facet-cut proposal you already sign. No cleanUpProdDiamond --auto step is needed (design: docs/DeferredDiamondCleanupQueue.md §6).
DRAIN_PARKED_TASKS=true in the environmentbefore Phase 2. Default off — keep it off for emergency / break-glass rollouts so unrelated removals never join an urgent signing set.
PR(s) (parkedTaskRefs) on the facet-cut proposal, shown at signing in confirm-safe-tx and in list-pending-proposals — so the signer sees why each facet is being removed.
blocks the primary proposal or the exit code — the removals simply don't fold in. But once folded, the removals execute atomically inside the primary's scheduleBatch: if a folded facet is removed by another path during the timelock delay, its diamondCut Remove reverts at execution and the whole batch — incl. the primary rollout cut — reverts (docs/DeferredDiamondCleanupQueue.md §6). Keep DRAIN_PARKED_TASKS off for time-critical rollouts if that risk is unacceptable.
MONGODB_URIcluster (DB deferred-cleanup), so the drain needs no tunnel — but it does need the role to have readWrite including index creation on that DB. If the role is createIndex-less (the grant drifted from timelock-operations), the drain still runs (the adapter degrades non-fatally), but until an admin creates the unique_open_task_key index, enqueue dedup is unenforced and you may see a loud DEDUP IS NOT ENFORCED warning. Fix is infra (grant readWrite+index on deferred-cleanup, or create the index once). See docs/DeferredDiamondCleanupQueue.md §5.
reconcile-parked-tasks job + TTL alert and the cleanUpProdDiamond --auto --all-networks backstop (spec §8) — not by this skill. That backstop still prints a conspicuous ⚠️ IRREVERSIBLE FACET REMOVAL banner and dry-runs without --yes; use it only for a deliberate cold-network sweep. See docs/FacetRemovalReconciliation.md.
Applies to a PolymerCCTPFacet rollout that adds a chain or CCTP corridor. The facet stores a chainId→CCTP-domain mapping per diamond, read from config/polymercctp.json. The newly deployed chain is seeded by the deploy's initPolymerCCTP init call, so it can route to every chain already in config the moment its cut executes — but every already-live chain still can't route to the new chain until the new chain's entry is added to their storage. PolymerCCTPFacet is the only facet with cross-chain chainId→domain storage today; a future one would follow the same shape.
Two triggers:
{ chainId, domainId } to config/polymercctp.json before the deploy (so it ships in the rollout PR and the init call is consistent), then propagate it to the already-live chains after the deploy.config/polymercctp.json and propagate only — skip the deploy (Phase 2) and the deployed-addresses PR; the config/polymercctp.json change ships in its own PR.Propagate (diff-driven — proposes only where the on-chain mapping is unset or stale, so re-running is safe and a too-wide network set no-ops):
bashbunx tsx script/tasks/proposePolymerCCTPChainIdMappings.ts --environment production
Variants: --network <name> (one chain), --excludeNetworks '["megaeth"]' (JSON array). Each proposal is a LiFiTimelockController.scheduleBatch wrapping setChainIdToDomainId, created carrying one signature — same lifecycle as every other proposal here.
Scope impact on the tail: these proposals land on chains beyond the deploy target — potentially every live PolymerCCTP chain. Fold them into the rest of the run — capture them in Phase 4, add their networks + nonces to the PR table in Phase 5 (and stage the config/polymercctp.json diff — it targets main), verify them in Phase 7, list their networks in the Phase 8 Slack post. The runner signs them alongside the deploy proposals in Phase 6.
bashbunx tsx script/deploy/safe/list-pending-proposals.ts --network <csv> --maxAgeHours 2 --json
Expect one pending proposal per succeeded network with signatureCount: 1 (the signature added at creation), plus one more when a diamond-called periphery's allowlist synced (registration + whitelist) — so one or two per network. The Phase 3.5 deferred-cleanup drain adds no extra proposal: its removals are folded into the network's facet-cut proposal as extra scheduleBatch elements (visible via that proposal's parkedTaskRefs), so do not wait for or count a separate removal proposal. Targets are the chain's LiFiTimelockController (proposals wrap in a timelock scheduleBatch). Keep nonce per network — the PR table needs it. Missing networks here mean the propose step failed even though the deploy succeeded — investigate before continuing; a periphery network showing only one proposal means its allowlist sync didn't land.
The deploy updated deployments/<net>.json (and staging logs if staging was deployed). If a diamond-called periphery's allowlist synced, updateWhitelistPeriphery.ts also rewrote config/whitelist.json (and config/whitelist.staging.json) on disk — that diff must ship in this PR too, or the repo's allowlist won't reflect the on-chain proposal. Model the PR on #1917:
Delegate the branch / commit / template / Linear ticket / push / create mechanic to /create-pr (as draft), passing:
config/whitelist.json / config/whitelist.staging.json diff from Phase 2's allowlist sync. (git status after the deploy shows exactly what to stage.) The whitelist diff is allowed here because this PR targets main (rule 502).| Chain | Contract address | Safe nonce | from Phases 2 and 4, plus the note that production <chain>.diamond.json registries update only when the cuts execute. For a periphery rollout, note the whitelist proposal and the whitelist.json update too.Don't reimplement branching/commit/PR plumbing here; /create-pr owns it (including the EXSC Linear-ticket requirement).
Whitelist mode changes no files — skip this phase; the input PR plays the PR role in the Slack post.
This is the one step the skill cannot run itself: confirm-safe-tx.ts is an interactive program that drives the user's Ledger over USB, so it must run in their terminal. Give them (lifi-connect tunnel required; Ledger is the default signer):
bashbunx tsx script/deploy/safe/confirm-safe-tx.ts
Variants if they ask: --network <name> (one chain), --ledgerLive --accountIndex <i> (Ledger Live derivation).
Then make the hand-back contract explicit — tell the user, in these words:
> Run the command above and approve each proposal on your Ledger. When you're done, come back to this chat and tell me "signed" (or "done"). I'll then verify the signatures and post the Slack thread for you. Please don't post to Slack or check the signatures yourself — those are my remaining steps (Phases 7–8); doing them by hand means I can't confirm the rollout actually completed.
Each proposal already carries one signature, so theirs makes 2 of the 3 required (the remaining signer comes from the Slack thread). Then stop and wait — do not proceed to Phase 7 until the user says they've signed. If they go quiet here, the rollout is unfinished: proposals sit at the runner's signature with no Slack ask. If the conversation resumes later with no signal either way, re-confirm by re-running Phase 7's check rather than assuming.
bashbunx tsx script/deploy/safe/list-pending-proposals.ts --network <csv> --status all --maxAgeHours 24 --json
Gate, per target network: a matching proposal that is pending with signatureCount >= 2 (the runner has added their signature on top of the one from creation — ready to recruit the remaining signer), or already submitted/executed (a fast signer beat the Slack post — fine, post anyway as a record). A network still at signatureCount: 1 (or with no row) means the runner's signature didn't land — go back to Phase 6 for those. Only proceed when every network passes.
Channel #dev-sc-multisig-proposals = C09DKGYQ1GC. Post as two messages (format verified against the live channel):
Top-level:
text<N>x <Contract> v<version> deployment
(whitelist mode: <N>x whitelist sync — <short PR title>)
Thread reply (capture ts from the top-level; @diamond_multisig_signers MUST be the subteam syntax — plain text does not notify). Signing pings the multisig-signer group, not the PR-review group @smartcontract_core — the signer set includes a non-core member:
text<!subteam^S0BKA0JRY0G> please sign/execute :pray: PR with deployed addresses: <PR URL> Safe proposals live on: • <network 1> • <network 2> …
(whitelist mode: label the link Whitelist PR: instead.)
Summarize: networks rolled out (+ failures and their state), proposal nonces, PR URL, Slack thread link, and what remains (team signatures/execution; timelock ops execute via the scheduled pipeline after the delay — once they have, finish with /finish-rollout <thread link>).
list-pending-proposals.ts exits 2 → tunnel down or SC_MONGODB_URI missing — tell the user, retry after they fix it.deploy-contract.confirm-safe-tx.ts explains them inline; relay its guidance (usually: delete + re-propose, or execute the blocking nonce first).| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-01 | fail→fail | 11,800 | 6,303 | -47% | 1 | 1 | 0% | 2,389 | 5,493 | +130% | 0 | 0 | — |
case-02 | fail→fail | 13,772 | 7,917 | -43% | 1 | 1 | 0% | 2,333 | 5,779 | +148% | 0 | 0 | — |
case-03 | fail→fail | 15,249 | 3,423 | -78% | 1 | 1 | 0% | 2,807 | 5,552 | +98% | 0 | 0 | — |
case-04 | pass→pass | 10,502 | 5,369 | -49% | 1 | 1 | 0% | 1,769 | 5,905 | +234% | 0 | 0 | — |
case-05 | fail→pass | 9,906 | 3,728 | -62% | 1 | 1 | 0% | 1,607 | 5,709 | +255% | 0 | 0 | — |
case-22 | fail→fail | 12,995 | 3,459 | -73% | 1 | 1 | 0% | 2,884 | 5,510 | +91% | 0 | 0 | — |
case-06 | fail→pass | 13,013 | 4,566 | -65% | 1 | 1 | 0% | 2,330 | 5,866 | +152% | 0 | 0 | — |
case-07 | fail→pass | 18,626 | 1,987 | -89% | 1 | 1 | 0% | 1,533 | 5,375 | +251% | 0 | 0 | — |
case-08 | fail→pass | 10,803 | 3,803 | -65% | 1 | 1 | 0% | 1,754 | 5,850 | +234% | 0 | 0 | — |
case-09 | fail→pass | 8,825 | 4,166 | -53% | 1 | 1 | 0% | 1,462 | 5,667 | +288% | 0 | 0 | — |
case-10 | fail→pass | 7,150 | 2,258 | -68% | 1 | 1 | 0% | 1,246 | 5,492 | +341% | 0 | 0 | — |
case-11 | pass→pass | 13,730 | 4,183 | -70% | 1 | 1 | 0% | 2,382 | 5,891 | +147% | 0 | 0 | — |
case-12 | fail→pass | 6,400 | 3,502 | -45% | 1 | 1 | 0% | 994 | 5,646 | +468% | 0 | 0 | — |
case-13 | fail→fail | 12,105 | 6,765 | -44% | 1 | 1 | 0% | 2,067 | 5,507 | +166% | 0 | 0 | — |
case-14 | fail→pass | 9,068 | 4,649 | -49% | 1 | 1 | 0% | 1,504 | 5,947 | +295% | 0 | 0 | — |
case-15 | fail→pass | 3,417 | 1,067 | -69% | 1 | 1 | 0% | 535 | 5,215 | +875% | 0 | 0 | — |
case-16 | fail→pass | 11,447 | 3,143 | -73% | 1 | 1 | 0% | 1,960 | 5,738 | +193% | 0 | 0 | — |
case-17 | pass→pass | 6,747 | 2,989 | -56% | 1 | 1 | 0% | 1,085 | 5,680 | +424% | 0 | 0 | — |
case-18 | fail→pass | 7,089 | 3,094 | -56% | 1 | 1 | 0% | 1,176 | 5,624 | +378% | 0 | 0 | — |
case-19 | pass→pass | 10,590 | 4,037 | -62% | 1 | 1 | 0% | 1,598 | 5,750 | +260% | 0 | 0 | — |
case-20 | fail→fail | 8,103 | 5,149 | -36% | 1 | 1 | 0% | 1,488 | 5,406 | +263% | 0 | 0 | — |
case-21 | fail→pass | 8,986 | 6,477 | -28% | 1 | 1 | 0% | 1,780 | 6,114 | +243% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted, and 18 counted toward the lift figure. The other 4 produced results that are not comparable between the two arms, so they are excluded from the headline rather than averaged into it. The headline lift of +55 percentage points is the difference between those two pass rates over the 18 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.