|
| 1 | +import { Address as LibplanetAddress } from "@planetarium/account"; |
| 2 | +import { encode } from "@planetarium/bencodex"; |
| 3 | +import { Currency, encodeSignedTx, signTx } from "@planetarium/tx"; |
| 4 | +import { Prisma, PrismaClient, RequestCategory, RequestType, ResponseType } from "@prisma/client"; |
| 5 | +import Decimal from "decimal.js"; |
| 6 | +import "dotenv/config"; |
| 7 | +import { randomBytes, randomUUID } from "node:crypto"; |
| 8 | +import { getAccountFromEnv } from "../accounts"; |
| 9 | +import { getEnv, getRequiredEnv } from "../env"; |
| 10 | +import { HeadlessGraphQLClient } from "../headless-graphql-client"; |
| 11 | +import { PreloadHandler } from "../preload-handler"; |
| 12 | +import { encodeTransferAssetAction } from "../actions/transfer"; |
| 13 | +import { SUPER_FUTURE_DATETIME, additionalGasTxProperties } from "../tx"; |
| 14 | +import { getTxId } from "../utils/tx"; |
| 15 | +import { getNextTxNonce } from "../sync/utils"; |
| 16 | +import { z } from "zod"; |
| 17 | + |
| 18 | +type Args = { |
| 19 | + to: string; |
| 20 | + amount: string; |
| 21 | + decimals: number; |
| 22 | + memo?: string; |
| 23 | +}; |
| 24 | + |
| 25 | +export function parseArgs(argv: string[]): Args { |
| 26 | + const map: Record<string, string> = {}; |
| 27 | + for (let i = 0; i < argv.length; i += 1) { |
| 28 | + const key = argv[i]; |
| 29 | + if (!key.startsWith("--")) { |
| 30 | + throw new Error(`Invalid argument: ${key}. Expected --key value form.`); |
| 31 | + } |
| 32 | + const value = argv[i + 1]; |
| 33 | + if (value === undefined || value.startsWith("--")) { |
| 34 | + throw new Error(`Missing value for ${key}`); |
| 35 | + } |
| 36 | + map[key.slice(2)] = value; |
| 37 | + i += 1; |
| 38 | + } |
| 39 | + |
| 40 | + const schema = z.object({ |
| 41 | + to: z.string().min(1), |
| 42 | + amount: z.string().min(1), |
| 43 | + decimals: z.coerce.number().int().min(0).max(30), |
| 44 | + memo: z.string().optional(), |
| 45 | + }); |
| 46 | + |
| 47 | + return schema.parse(map); |
| 48 | +} |
| 49 | + |
| 50 | +export function parseLibplanetAddress(hex: string): LibplanetAddress { |
| 51 | + try { |
| 52 | + return LibplanetAddress.fromHex(hex, true); |
| 53 | + } catch { |
| 54 | + return LibplanetAddress.fromHex(hex, false); |
| 55 | + } |
| 56 | +} |
| 57 | + |
| 58 | +export function makeAdhocId(): string { |
| 59 | + // Node.js 16+ supports randomUUID, but keep a fallback. |
| 60 | + const uuid = |
| 61 | + typeof randomUUID === "function" |
| 62 | + ? randomUUID() |
| 63 | + : randomBytes(16).toString("hex"); |
| 64 | + return `adhoc:${uuid}`; |
| 65 | +} |
| 66 | + |
| 67 | +export function parseDecimalToRawValue(amount: string, decimals: number): bigint { |
| 68 | + const d = new Decimal(amount); |
| 69 | + if (!d.isFinite()) { |
| 70 | + throw new Error(`Invalid --amount: ${amount}`); |
| 71 | + } |
| 72 | + if (d.isNeg()) { |
| 73 | + throw new Error("--amount must be non-negative."); |
| 74 | + } |
| 75 | + |
| 76 | + const scale = new Decimal(10).pow(decimals); |
| 77 | + const raw = d.mul(scale); |
| 78 | + if (!raw.isInteger()) { |
| 79 | + throw new Error( |
| 80 | + `--amount has more fractional digits than --decimals (${decimals}).`, |
| 81 | + ); |
| 82 | + } |
| 83 | + return BigInt(raw.toFixed(0)); |
| 84 | +} |
| 85 | + |
| 86 | +export function buildNcgCurrency( |
| 87 | + decimals: number, |
| 88 | + upstreamNcgMinter: LibplanetAddress, |
| 89 | +): Currency { |
| 90 | + return { |
| 91 | + ticker: "NCG", |
| 92 | + decimalPlaces: decimals, |
| 93 | + totalSupplyTrackable: false, |
| 94 | + minters: new Set([upstreamNcgMinter.toBytes()]), |
| 95 | + maximumSupply: null, |
| 96 | + }; |
| 97 | +} |
| 98 | + |
| 99 | +export async function main() { |
| 100 | + const args = parseArgs(process.argv.slice(2)); |
| 101 | + |
| 102 | + // Ensure required envs exist early. |
| 103 | + getRequiredEnv("DATABASE_URL"); |
| 104 | + getRequiredEnv("NC_REGISTRY_ENDPOINT"); |
| 105 | + getRequiredEnv("NC_UPSTREAM_PLANET"); |
| 106 | + getRequiredEnv("NC_DOWNSTREAM_PLANET"); |
| 107 | + |
| 108 | + const [upstreamPlanet] = await new PreloadHandler().preparePlanets(); |
| 109 | + const upstreamGQLClient = new HeadlessGraphQLClient(upstreamPlanet); |
| 110 | + const upstreamNetworkId = upstreamGQLClient.getPlanetID(); |
| 111 | + |
| 112 | + const upstreamAccount = getAccountFromEnv("NC_UPSTREAM"); |
| 113 | + const signerAddress = await upstreamAccount.getAddress(); |
| 114 | + |
| 115 | + const recipient = parseLibplanetAddress(args.to); |
| 116 | + |
| 117 | + const upstreamNcgMinter = parseLibplanetAddress( |
| 118 | + getEnv("NC_UPSTREAM_NCG_MINTER") || |
| 119 | + "47d082a115c63e7b58b1532d20e631538eafadde", |
| 120 | + ); |
| 121 | + |
| 122 | + const currency = buildNcgCurrency(args.decimals, upstreamNcgMinter); |
| 123 | + const rawValue = parseDecimalToRawValue(args.amount, args.decimals); |
| 124 | + |
| 125 | + const genesisHash = Buffer.from(await upstreamGQLClient.getGenesisHash(), "hex"); |
| 126 | + |
| 127 | + const prisma = new PrismaClient(); |
| 128 | + await prisma.$connect(); |
| 129 | + try { |
| 130 | + // Make sure Network row exists. (No schema change; safe idempotent.) |
| 131 | + await prisma.network.upsert({ |
| 132 | + where: { id: upstreamNetworkId }, |
| 133 | + create: { id: upstreamNetworkId }, |
| 134 | + update: {}, |
| 135 | + }); |
| 136 | + |
| 137 | + const maxAttempts = 3; |
| 138 | + let lastError: unknown = null; |
| 139 | + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { |
| 140 | + const lastBlock = await prisma.block.findFirst({ |
| 141 | + where: { networkId: upstreamNetworkId }, |
| 142 | + orderBy: { index: "desc" }, |
| 143 | + select: { index: true }, |
| 144 | + }); |
| 145 | + if (!lastBlock) { |
| 146 | + throw new Error( |
| 147 | + `No Block row exists for networkId=${upstreamNetworkId}. Run the bridge at least once so it scans blocks before using this ad-hoc script.`, |
| 148 | + ); |
| 149 | + } |
| 150 | + |
| 151 | + const nonce = await prisma.$transaction(async (tx) => { |
| 152 | + return await getNextTxNonce( |
| 153 | + tx as any, |
| 154 | + upstreamGQLClient, |
| 155 | + upstreamAccount, |
| 156 | + ); |
| 157 | + }); |
| 158 | + |
| 159 | + const action = encodeTransferAssetAction( |
| 160 | + recipient, |
| 161 | + signerAddress, |
| 162 | + { currency, rawValue }, |
| 163 | + args.memo ?? null, |
| 164 | + ); |
| 165 | + |
| 166 | + const unsignedTx = { |
| 167 | + nonce, |
| 168 | + genesisHash, |
| 169 | + publicKey: (await upstreamAccount.getPublicKey()).toBytes( |
| 170 | + "uncompressed", |
| 171 | + ), |
| 172 | + signer: signerAddress.toBytes(), |
| 173 | + timestamp: SUPER_FUTURE_DATETIME, |
| 174 | + updatedAddresses: new Set([]), |
| 175 | + actions: [action], |
| 176 | + ...additionalGasTxProperties, |
| 177 | + }; |
| 178 | + |
| 179 | + const signedTx = await signTx(unsignedTx as any, upstreamAccount); |
| 180 | + const serializedTx = encode(encodeSignedTx(signedTx)); |
| 181 | + const raw = Buffer.from(serializedTx); |
| 182 | + const txid = getTxId(raw); |
| 183 | + |
| 184 | + const requestId = makeAdhocId(); |
| 185 | + |
| 186 | + try { |
| 187 | + await prisma.$transaction(async (tx) => { |
| 188 | + await tx.requestTransaction.create({ |
| 189 | + data: { |
| 190 | + id: requestId, |
| 191 | + category: RequestCategory.IGNORE, |
| 192 | + type: RequestType.TRANSFER_ASSET, |
| 193 | + networkId: upstreamNetworkId, |
| 194 | + blockIndex: lastBlock.index, |
| 195 | + sender: signerAddress.toString(), |
| 196 | + }, |
| 197 | + }); |
| 198 | + |
| 199 | + await tx.responseTransaction.create({ |
| 200 | + data: { |
| 201 | + id: txid, |
| 202 | + nonce, |
| 203 | + raw, |
| 204 | + type: ResponseType.TRANSFER_ASSET, |
| 205 | + networkId: upstreamNetworkId, |
| 206 | + requestTransactionId: requestId, |
| 207 | + }, |
| 208 | + }); |
| 209 | + }); |
| 210 | + |
| 211 | + console.log("Enqueued ad-hoc upstream transfer."); |
| 212 | + console.log("requestId:", requestId); |
| 213 | + console.log("networkId:", upstreamNetworkId); |
| 214 | + console.log("nonce:", nonce.toString()); |
| 215 | + console.log("txid:", txid); |
| 216 | + console.log("to:", recipient.toString()); |
| 217 | + console.log("amount:", args.amount); |
| 218 | + console.log("decimals:", args.decimals); |
| 219 | + console.log("rawValue:", rawValue.toString()); |
| 220 | + if (args.memo) console.log("memo:", args.memo); |
| 221 | + return; |
| 222 | + } catch (e) { |
| 223 | + lastError = e; |
| 224 | + if ( |
| 225 | + e instanceof Prisma.PrismaClientKnownRequestError && |
| 226 | + e.code === "P2002" |
| 227 | + ) { |
| 228 | + console.warn( |
| 229 | + `Unique constraint conflict while inserting (attempt ${attempt}/${maxAttempts}). Retrying...`, |
| 230 | + ); |
| 231 | + continue; |
| 232 | + } |
| 233 | + throw e; |
| 234 | + } |
| 235 | + } |
| 236 | + |
| 237 | + throw lastError instanceof Error |
| 238 | + ? lastError |
| 239 | + : new Error("Failed to enqueue after retries."); |
| 240 | + } finally { |
| 241 | + await prisma.$disconnect(); |
| 242 | + } |
| 243 | +} |
| 244 | + |
| 245 | +if (require.main === module) { |
| 246 | + main().catch((e) => { |
| 247 | + console.error(e); |
| 248 | + process.exitCode = 1; |
| 249 | + }); |
| 250 | +} |
| 251 | + |
0 commit comments