Skip to content

Commit 2a0c238

Browse files
committed
feat: add adhoc script
1 parent 1ee74eb commit 2a0c238

4 files changed

Lines changed: 358 additions & 0 deletions

File tree

CONTRIBUTING.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,29 @@ yarn codegen
8181
yarn start
8282
```
8383

84+
## Ad-hoc upstream transfer (enqueue to DB)
85+
86+
This project includes an ad-hoc script that **creates an upstream `transfer_asset` transaction and enqueues it into DB** as a synthetic `RequestTransaction` + `ResponseTransaction` pair. The bridge process will pick it up and stage it via the existing periodic staging loop.
87+
88+
### Usage
89+
90+
```bash
91+
# Note: pass args after `--` when using yarn scripts
92+
yarn adhoc:upstream-transfer -- --to <RECIPIENT_ADDRESS_HEX> --amount <DECIMAL_AMOUNT> --decimals 18
93+
94+
# Optional memo
95+
yarn adhoc:upstream-transfer -- --to <RECIPIENT_ADDRESS_HEX> --amount 12.34 --decimals 18 --memo "hello"
96+
```
97+
98+
### Notes / Safety
99+
100+
- The script **does NOT stage immediately**; it only enqueues to DB (`enqueue_only`).
101+
- If the bridge (`yarn start`) is running, it will stage within ~5 seconds (via `stageTransactionFromDB()`).
102+
- The script requires that the bridge has scanned at least one block for the upstream network already.
103+
- It intentionally reuses the latest scanned `Block.index` for the synthetic request to avoid breaking the sync cursor.
104+
- `--amount` is a decimal string; the script converts it to raw value using `--decimals`.
105+
- If `--amount` has more fractional digits than `--decimals`, it fails.
106+
84107
## Code Style
85108

86109
This project uses Biome as a code formatter:

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
"private": true,
55
"scripts": {
66
"compile": "prisma generate; graphql-codegen; tsc",
7+
"adhoc:upstream-transfer": "npx tsx ./src/scripts/adhoc-upstream-transfer.ts",
8+
"test": "jest",
79
"start": "npx tsx ./src/index.ts",
810
"codegen": "prisma generate; graphql-codegen"
911
},
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/// <reference types="jest" />
2+
3+
import {
4+
makeAdhocId,
5+
parseArgs,
6+
parseDecimalToRawValue,
7+
} from "../adhoc-upstream-transfer";
8+
9+
describe("adhoc-upstream-transfer helpers", () => {
10+
test("makeAdhocId prefixes adhoc:", () => {
11+
const id = makeAdhocId();
12+
expect(id.startsWith("adhoc:")).toBe(true);
13+
expect(id.length).toBeGreaterThan("adhoc:".length);
14+
});
15+
16+
describe("parseArgs", () => {
17+
test("parses required args", () => {
18+
const args = parseArgs([
19+
"--to",
20+
"abcdef",
21+
"--amount",
22+
"12.34",
23+
"--decimals",
24+
"18",
25+
]);
26+
expect(args).toEqual({
27+
to: "abcdef",
28+
amount: "12.34",
29+
decimals: 18,
30+
});
31+
});
32+
33+
test("parses optional memo", () => {
34+
const args = parseArgs([
35+
"--to",
36+
"abcdef",
37+
"--amount",
38+
"1",
39+
"--decimals",
40+
"0",
41+
"--memo",
42+
"hello",
43+
]);
44+
expect(args.memo).toBe("hello");
45+
});
46+
47+
test("throws on missing value", () => {
48+
expect(() => parseArgs(["--to", "abc", "--amount"])).toThrow(
49+
/Missing value/,
50+
);
51+
});
52+
53+
test("throws on non --key token", () => {
54+
expect(() => parseArgs(["to", "abc"])).toThrow(/Invalid argument/);
55+
});
56+
});
57+
58+
describe("parseDecimalToRawValue", () => {
59+
test("converts integer amounts", () => {
60+
expect(parseDecimalToRawValue("1", 18)).toBe(10n ** 18n);
61+
expect(parseDecimalToRawValue("0", 18)).toBe(0n);
62+
});
63+
64+
test("converts fractional amounts at 18 decimals", () => {
65+
expect(parseDecimalToRawValue("0.000000000000000001", 18)).toBe(1n);
66+
expect(parseDecimalToRawValue("12.34", 2)).toBe(1234n);
67+
});
68+
69+
test("rejects too many fractional digits", () => {
70+
expect(() => parseDecimalToRawValue("0.001", 2)).toThrow(
71+
/more fractional digits/,
72+
);
73+
});
74+
75+
test("rejects negative", () => {
76+
expect(() => parseDecimalToRawValue("-1", 18)).toThrow(
77+
/non-negative/,
78+
);
79+
});
80+
});
81+
});
82+
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
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

Comments
 (0)