|
| 1 | +// Copyright (C) Parity Technologies (UK) Ltd. |
| 2 | +// SPDX-License-Identifier: Apache-2.0 |
| 3 | +// Licensed under the Apache License, Version 2.0 (the "License"); |
| 4 | +// you may not use this file except in compliance with the License. |
| 5 | +// You may obtain a copy of the License at |
| 6 | +// |
| 7 | +// http://www.apache.org/licenses/LICENSE-2.0 |
| 8 | +// |
| 9 | +// Unless required by applicable law or agreed to in writing, software |
| 10 | +// distributed under the License is distributed on an "AS IS" BASIS, |
| 11 | +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 12 | +// See the License for the specific language governing permissions and |
| 13 | +// limitations under the License. |
| 14 | + |
| 15 | +/** |
| 16 | + * drain-product-account.ts — move native PAS + PGAS out of one of YOUR |
| 17 | + * phone-controlled product accounts, signed on your phone via the live CLI |
| 18 | + * session. Built to put a product account below the playground-app funds |
| 19 | + * floors (native < 0.3 PAS AND PGAS < 5B) so the "Become a builder" resource |
| 20 | + * drip fires on a star — but it's a general "move funds between my own product |
| 21 | + * accounts" tool (set PRODUCT_ID + DEST to drain or refund). |
| 22 | + * |
| 23 | + * WHY THIS TOOL EXISTS / KEY INSIGHTS |
| 24 | + * - A product account (e.g. the playground-app's `5FNup4…`) is soft-derived |
| 25 | + * from the wallet root as `/product/{PRODUCT_ID}/{index}`. The SAME phone |
| 26 | + * signs for ANY product account of that root — you just point the signer at |
| 27 | + * the right PRODUCT_ID. Here, the playground.dot CLI session signs for a |
| 28 | + * DIFFERENT app's account (`pr-472-playgroundtest.dot/0`) by passing that |
| 29 | + * productId to createPlaygroundSessionSigner. Verified: same root |
| 30 | + * `5Ek9owHF…` → playground.dot/0 = 5CcaUS3…, pr-472-playgroundtest.dot/0 = 5FNup4…. |
| 31 | + * - HARD REQUIREMENT: the CLI repo must be at a version whose host-papp can |
| 32 | + * DECODE your current session. The phone app writes SsoSessionsV3 with a |
| 33 | + * required `deviceEncPubKey` (host-papp 0.8.7 / product-sdk-terminal 0.5.0, |
| 34 | + * CLI ≥ v0.40.x). An older repo (e.g. v0.37 / host-papp 0.8.6) silently |
| 35 | + * fails to decode → getSessionSigner() returns null → "NO SESSION". If you |
| 36 | + * see that, `git checkout v0.40.2 && pnpm install` (or newer) first. |
| 37 | + * - Fees: paid in PGAS via ChargeAssetTxPayment (PGAS_FEE), matching how |
| 38 | + * product-account txs are charged. Pattern lifted from polkadot-app-deploy's |
| 39 | + * PGAS_FEE_OPTIONS (src/dotns.ts). |
| 40 | + * |
| 41 | + * RUN (from the playground-cli repo root, with the matching version installed): |
| 42 | + * bun tools/drain-product-account.ts # executes (2 phone taps) |
| 43 | + * DRY_RUN=1 bun tools/drain-product-account.ts # print plan only, no submit |
| 44 | + * |
| 45 | + * Each transfer is a phone approval — nothing moves without your tap. |
| 46 | + */ |
| 47 | + |
| 48 | +import { getSessionSigner } from "../src/utils/auth.ts"; |
| 49 | +import { createPlaygroundSessionSigner } from "../src/utils/sessionSigner.ts"; |
| 50 | +import { createClient, Enum } from "polkadot-api"; |
| 51 | +import { getWsProvider } from "polkadot-api/ws"; |
| 52 | +import { paseo_asset_hub } from "@parity/product-sdk-descriptors/paseo-asset-hub"; |
| 53 | +import { ss58Encode } from "@parity/product-sdk-address"; |
| 54 | + |
| 55 | +// ── config — edit these ──────────────────────────────────────────────────── |
| 56 | +/** Which product account to move funds OUT of (its productId/index off your |
| 57 | + * root). "pr-472-playgroundtest.dot" → 5FNup4… ; "playground.dot" → 5CcaUS3… */ |
| 58 | +const PRODUCT_ID = "pr-472-playgroundtest.dot"; |
| 59 | +const DERIVATION_INDEX = 0; |
| 60 | +/** Destination — one of YOUR other product accounts (funds stay recoverable). */ |
| 61 | +const DEST = "5CcaUS3AKQyJaVLeFGTwBMJPPpoEcgS55g64Fws4UdLqbcmC"; |
| 62 | +/** What to LEAVE behind (planck / PGAS units). Defaults drop below the app's |
| 63 | + * funds floors (0.3 PAS = 3e9 planck, 5B PGAS). Set 0n to sweep everything. */ |
| 64 | +const LEAVE_PAS_PLANCK = 2_000_000_000n; // 0.2 PAS |
| 65 | +const LEAVE_PGAS = 1_000_000_000n; // ~1B PGAS (covers the in-flight tx fees) |
| 66 | +const ASSET_HUB_RPC = "wss://paseo-asset-hub-next-rpc.polkadot.io"; |
| 67 | +// ─────────────────────────────────────────────────────────────────────────── |
| 68 | + |
| 69 | +const PGAS_ID = 2_000_000_000; |
| 70 | +const DRY_RUN = process.env.DRY_RUN === "1"; |
| 71 | +const fmt = (p: bigint) => (Number(p) / 1e10).toFixed(4) + " PAS"; |
| 72 | + |
| 73 | +// ChargeAssetTxPayment → PGAS (PalletInstance 50, GeneralIndex = asset id). |
| 74 | +const PGAS_FEE = { |
| 75 | + customSignedExtensions: { |
| 76 | + ChargeAssetTxPayment: { |
| 77 | + value: { |
| 78 | + tip: 0n, |
| 79 | + asset_id: { |
| 80 | + parents: 0, |
| 81 | + interior: { |
| 82 | + type: "X2", |
| 83 | + value: [ |
| 84 | + { type: "PalletInstance", value: 50 }, |
| 85 | + { type: "GeneralIndex", value: BigInt(PGAS_ID) }, |
| 86 | + ], |
| 87 | + }, |
| 88 | + }, |
| 89 | + }, |
| 90 | + }, |
| 91 | + }, |
| 92 | +}; |
| 93 | + |
| 94 | +const handle = await getSessionSigner(); |
| 95 | +if (!handle) { |
| 96 | + console.error( |
| 97 | + "NO SESSION. Either run `playground login`, or the repo is too old to decode\n" + |
| 98 | + "your session (need host-papp 0.8.7 / CLI >= v0.40.x: `git checkout v0.40.2 && pnpm install`).", |
| 99 | + ); |
| 100 | + process.exit(1); |
| 101 | +} |
| 102 | + |
| 103 | +const signer = createPlaygroundSessionSigner(handle.userSession, { |
| 104 | + productId: PRODUCT_ID, |
| 105 | + derivationIndex: DERIVATION_INDEX, |
| 106 | +}); |
| 107 | +const src = ss58Encode(signer.publicKey); |
| 108 | +console.log(`source ${PRODUCT_ID}/${DERIVATION_INDEX} -> ${src}`); |
| 109 | +console.log(`dest -> ${DEST}`); |
| 110 | + |
| 111 | +const client = createClient(getWsProvider([ASSET_HUB_RPC])); |
| 112 | +const api = client.getTypedApi(paseo_asset_hub); |
| 113 | +const free = (await api.query.System.Account.getValue(src))?.data?.free ?? 0n; |
| 114 | +const pgas = (await api.query.Assets.Account.getValue(PGAS_ID, src))?.balance ?? 0n; |
| 115 | +console.log(`BEFORE native ${fmt(free)} PGAS ${pgas}`); |
| 116 | + |
| 117 | +const pgasSend = pgas - LEAVE_PGAS; |
| 118 | +const pasSend = free - LEAVE_PAS_PLANCK; |
| 119 | +console.log(`PLAN send PGAS ${pgasSend} + PAS ${pasSend} (${fmt(pasSend)}) -> ${DEST}`); |
| 120 | + |
| 121 | +if (DRY_RUN) { |
| 122 | + console.log("DRY_RUN=1 — nothing submitted."); |
| 123 | + client.destroy(); |
| 124 | + handle.destroy(); |
| 125 | + process.exit(0); |
| 126 | +} |
| 127 | + |
| 128 | +if (pgasSend > 0n) { |
| 129 | + console.log("→ TX1 Assets.transfer PGAS (approve on phone)…"); |
| 130 | + const r = await api.tx.Assets |
| 131 | + .transfer({ id: PGAS_ID, target: Enum("Id", DEST), amount: pgasSend }) |
| 132 | + .signAndSubmit(signer, PGAS_FEE); |
| 133 | + console.log(` TX1 ${r.ok ? "OK" : "FAILED"} ${r.txHash}`); |
| 134 | +} |
| 135 | +if (pasSend > 0n) { |
| 136 | + console.log("→ TX2 Balances.transfer_keep_alive PAS (approve on phone)…"); |
| 137 | + const r = await api.tx.Balances |
| 138 | + .transfer_keep_alive({ dest: Enum("Id", DEST), value: pasSend }) |
| 139 | + .signAndSubmit(signer, PGAS_FEE); |
| 140 | + console.log(` TX2 ${r.ok ? "OK" : "FAILED"} ${r.txHash}`); |
| 141 | +} |
| 142 | + |
| 143 | +const free2 = (await api.query.System.Account.getValue(src))?.data?.free ?? 0n; |
| 144 | +const pgas2 = (await api.query.Assets.Account.getValue(PGAS_ID, src))?.balance ?? 0n; |
| 145 | +console.log(`AFTER native ${fmt(free2)} PGAS ${pgas2}`); |
| 146 | +console.log( |
| 147 | + `below app floors? native<0.3PAS:${free2 < 3_000_000_000n} pgas<5B:${pgas2 < 5_000_000_000n}`, |
| 148 | +); |
| 149 | +client.destroy(); |
| 150 | +handle.destroy(); |
| 151 | +process.exit(0); |
0 commit comments