Skip to content

Commit 420d15e

Browse files
fix(cli): commit-age poll uses block-time instead of wall-clock (#105)
## Summary `waitForMinimumCommitmentAge` verifies the commitment is mature by comparing **wall-clock** `Date.now()` against the commit's block timestamp. The contract's `CommitmentTooNew` check uses `block.timestamp`, which can lag wall-clock by several seconds (especially on parachains between blocks). The verify poll then says "safe" under wall-clock math and submits the reveal while the contract still sees the commitment as too new — reverts with: ``` Contract reverted: CommitmentTooNew(0x3d2f…, 1777012650, 1777012644) ^minReveal ^currentBlock ``` exactly `DEFAULT_COMMITMENT_BUFFER_SECONDS` (**6 s**) short. The buffer that's supposed to absorb this gets consumed by the block-time lag. ## Fix Read the chain's current `block.timestamp` via the **Timestamp pallet** (`Timestamp::Now` returns block-time in ms) and use that as "now" in the poll comparison. Wall-clock stays as the sleep timer and as the `pollDeadline` budget — only the verify criterion changes. Falls back to wall-clock if the storage item isn't available on the runtime (defensive — every Substrate chain we target has it). ```diff + let chainNowSeconds: number; + if (timestampQuery?.getValue) { + const timestampMs = await timestampQuery.getValue(); + chainNowSeconds = Math.floor(Number(timestampMs) / 1000); + } else { + chainNowSeconds = Math.floor(Date.now() / 1000); + } - const nowSeconds = Math.floor(Date.now() / 1000); - if (polledCommitTime > 0 && nowSeconds - polledCommitTime >= minimumAgeSeconds) { + if (polledCommitTime > 0 && chainNowSeconds - polledCommitTime >= minimumAgeSeconds) { // ... } ``` ## Repro Nightly E2E against Paseo Asset Hub, using bulletin-deploy's in-flight `#158` subprocess adapter (which invokes `dotns register domain`). One of 8 scenarios (S2 fresh pool/js) reverted with `CommitmentTooNew` exactly 6s short. The companion S2 fresh direct/js scenario in the same run passed — it hit the polling loop at least once, giving block-time more wall-clock to catch up. With this fix, the race disappears entirely because the loop only exits when the **chain's** clock says the age is met. ## Test plan - [x] `bunx tsc --noEmit` — no type errors. - [ ] Integration test against Paseo AH — will re-run bulletin-deploy's nightly pass pointing at a post-merge cut of this PR. ## Why not just raise `DEFAULT_COMMITMENT_BUFFER_SECONDS`? Bumping the buffer is a band-aid — it trades wall-clock latency for a probability of success. At 6 s we see occasional failures under normal Paseo block-time drift. Raising to 30 s would mask most failures but not all, and would add 24 s of mandatory sleep to every register. The proper fix is deterministic: compare the same clock the contract uses. --- No `Co-Authored-By` per upstream conventions. --------- Co-authored-by: Siphamandla Mjoli <siphamandla@parity.io>
1 parent 4bc1e3f commit 420d15e

25 files changed

Lines changed: 318 additions & 216 deletions

packages/cli/src/cli/commands/lookup.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Command } from "commander";
22
import chalk from "chalk";
3+
import type { KeyringPair } from "@polkadot/keyring/types";
34
import { createClient } from "polkadot-api";
45
import { getWsProvider } from "polkadot-api/ws-provider";
56
import { paseo } from "@polkadot-api/descriptors";
@@ -12,6 +13,7 @@ import {
1213
resolveAuthSourceReadOnly,
1314
resolveAuthSource,
1415
createAccountFromSource,
16+
createSubstrateSigner,
1517
} from "../../commands/auth";
1618
import { addAuthOptions, getAuthOptions } from "./authOptions";
1719
import { step } from "../ui";
@@ -85,6 +87,8 @@ export async function prepareReadOnlyContext(
8587
createAccountFromSource(auth.source, auth.isKeyUri),
8688
);
8789

90+
await ensureAccountMappedWhenAuthenticated(clientWrapper, keypair, auth.resolvedFrom);
91+
8892
const evmAddress = await step("Resolving EVM address", async () =>
8993
clientWrapper.getEvmAddress(keypair.address),
9094
);
@@ -95,6 +99,26 @@ export async function prepareReadOnlyContext(
9599
return { clientWrapper, account: { address: keypair.address }, rpc, evmAddress };
96100
}
97101

102+
export async function ensureAccountMappedWhenAuthenticated(
103+
clientWrapper: ReviveClientWrapper,
104+
keypair: KeyringPair,
105+
resolvedFrom: ResolvedReadOnlyAuth["resolvedFrom"],
106+
): Promise<void> {
107+
if (resolvedFrom === "default") return;
108+
const signer = createSubstrateSigner(keypair);
109+
try {
110+
await step("Ensuring account mapped", async () =>
111+
clientWrapper.ensureAccountMapped(keypair.address, signer),
112+
);
113+
} catch (mapError) {
114+
console.log(
115+
chalk.yellow(
116+
` ⚠ Account mapping skipped: ${mapError instanceof Error ? mapError.message : String(mapError)}`,
117+
),
118+
);
119+
}
120+
}
121+
98122
async function resolveRecipientByKind(
99123
clientWrapper: ReviveClientWrapper,
100124
substrateAddress: string,

packages/cli/src/cli/context.ts

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
import chalk from "chalk";
22
import { createClient } from "polkadot-api";
33
import { getWsProvider } from "polkadot-api/ws-provider";
4-
import { getPolkadotSigner } from "polkadot-api/signer";
54
import { bulletin, paseo } from "@polkadot-api/descriptors";
65
import { type Address } from "viem";
76
import { ReviveClientWrapper, type PolkadotApiClient } from "../client/polkadotClient";
87
import { parseNativeBalance, formatNativeBalance } from "../utils/formatting";
98
import { resolveRpc, resolveMinBalancePas, resolveKeystorePath } from "./env";
109
import { step } from "./ui";
11-
import { resolveAuthSource, createAccountFromSource } from "../commands/auth";
10+
import {
11+
resolveAuthSource,
12+
createAccountFromSource,
13+
createSubstrateSigner,
14+
} from "../commands/auth";
1215
import type { AssetHubContext, BulletinContext, ChainContext, BalanceStatus } from "../types/types";
1316

1417
export async function getBalanceStatus(
@@ -72,9 +75,7 @@ export async function prepareAssetHubContext(options: any): Promise<AssetHubCont
7275
);
7376

7477
const substrateAddress = account.address;
75-
const signer = getPolkadotSigner(account.publicKey, "Sr25519", async (input) =>
76-
account.sign(input),
77-
);
78+
const signer = createSubstrateSigner(account);
7879

7980
const clientWrapper = new ReviveClientWrapper(client as PolkadotApiClient);
8081

@@ -144,9 +145,7 @@ export async function prepareBulletinContext(options: any): Promise<BulletinCont
144145
);
145146

146147
const substrateAddress = account.address;
147-
const signer = getPolkadotSigner(account.publicKey, "Sr25519", async (input) =>
148-
account.sign(input),
149-
);
148+
const signer = createSubstrateSigner(account);
150149

151150
console.log(chalk.bold("\n📋 Configuration\n"));
152151
console.log(chalk.gray(" RPC: ") + chalk.white(rpc));

packages/cli/src/commands/auth.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { Keyring } from "@polkadot/keyring";
2+
import type { KeyringPair } from "@polkadot/keyring/types";
23
import { cryptoWaitReady } from "@polkadot/util-crypto";
4+
import { getPolkadotSigner } from "polkadot-api/signer";
5+
import type { PolkadotSigner } from "polkadot-api";
36
import path from "node:path";
47
import fs from "node:fs/promises";
58

@@ -17,6 +20,10 @@ export async function createAccountFromSource(source: string, isKeyUri: boolean)
1720
return isKeyUri ? keyring.addFromUri(source) : keyring.addFromMnemonic(source);
1821
}
1922

23+
export function createSubstrateSigner(keypair: KeyringPair): PolkadotSigner {
24+
return getPolkadotSigner(keypair.publicKey, "Sr25519", async (input) => keypair.sign(input));
25+
}
26+
2027
async function readDefaultAccountName(keystoreDirectoryPath: string): Promise<string | undefined> {
2128
try {
2229
const defaultPointerPath = path.join(keystoreDirectoryPath, ".default");

packages/cli/src/commands/register.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,16 @@ export async function waitForMinimumCommitmentAge(
257257

258258
waitSpinner.text = "Verifying commitment age on-chain";
259259

260+
// Compare block-time to block-time, not wall-clock to block-time. The
261+
// contract's CommitmentTooNew check is `block.timestamp - commitTimestamp
262+
// >= minCommitmentAge`, so the verify poll has to use the chain's current
263+
// block timestamp as "now" — otherwise block-time can lag wall-clock by
264+
// several seconds (e.g. on a parachain between blocks) and we submit the
265+
// reveal while the contract still sees the commitment as too new. The
266+
// Timestamp pallet stores block.timestamp in milliseconds.
260267
const pollDeadline = Date.now() + COMMITMENT_POLL_TIMEOUT_MS;
268+
269+
const timestampQuery = (clientWrapper.client as any).query?.Timestamp?.Now;
261270
while (Date.now() < pollDeadline) {
262271
const polledTimestamp = await performContractCall<bigint | number>(
263272
clientWrapper,
@@ -271,9 +280,18 @@ export async function waitForMinimumCommitmentAge(
271280
const polledCommitTime =
272281
typeof polledTimestamp === "bigint" ? Number(polledTimestamp) : polledTimestamp;
273282

274-
const nowSeconds = Math.floor(Date.now() / 1000);
283+
// Prefer chain block.timestamp via Timestamp::Now; fall back to wall-clock
284+
// only if the pallet storage isn't available on this runtime (defensive —
285+
// every Substrate chain we care about has it).
286+
let chainNowSeconds: number;
287+
if (timestampQuery?.getValue) {
288+
const timestampMs = (await timestampQuery.getValue()) as bigint | number;
289+
chainNowSeconds = Math.floor(Number(timestampMs) / 1000);
290+
} else {
291+
chainNowSeconds = Math.floor(Date.now() / 1000);
292+
}
275293

276-
if (polledCommitTime > 0 && nowSeconds - polledCommitTime >= minimumAgeSeconds) {
294+
if (polledCommitTime > 0 && chainNowSeconds - polledCommitTime >= minimumAgeSeconds) {
277295
waitSpinner.succeed("Commitment age requirement met (verified on-chain)");
278296
return;
279297
}

packages/cli/src/simpleExamples/00_shared.ts

Lines changed: 6 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,21 @@
11
import { createClient } from "polkadot-api";
2-
import { getPolkadotSigner } from "polkadot-api/signer";
32
import { getWsProvider } from "polkadot-api/ws-provider";
43
import { bulletin, paseo } from "@polkadot-api/descriptors";
5-
import { Keyring } from "@polkadot/keyring";
6-
import { cryptoWaitReady } from "@polkadot/util-crypto";
4+
import type { PolkadotSigner } from "polkadot-api";
75
import { type Address } from "viem";
86

97
import { ReviveClientWrapper, type PolkadotApiClient } from "../client/polkadotClient";
10-
import { DEFAULT_MNEMONIC, RPC_ENDPOINTS } from "../utils/constants";
11-
const DEFAULT_BULLETIN_RPC = "wss://paseo-bulletin-rpc.polkadot.io";
8+
import { DEFAULT_BULLETIN_RPC, DEFAULT_MNEMONIC, RPC_ENDPOINTS } from "../utils/constants";
9+
import { createAccountFromSource, createSubstrateSigner } from "../commands/auth";
1210

1311
export type ConnectedDotns = {
1412
client: PolkadotApiClient;
1513
clientWrapper: ReviveClientWrapper;
1614
substrateAddress: string;
1715
evmAddress: Address;
18-
signer: ReturnType<typeof getPolkadotSigner>;
16+
signer: PolkadotSigner;
1917
};
2018

21-
export async function createAccountFromSource(source: string, isKeyUri: boolean) {
22-
await cryptoWaitReady();
23-
const keyring = new Keyring({ type: "sr25519" });
24-
return isKeyUri ? keyring.addFromUri(source) : keyring.addFromMnemonic(source);
25-
}
26-
2719
export async function connectDotns(): Promise<ConnectedDotns> {
2820
const rpc = process.env.DOTNS_RPC ?? RPC_ENDPOINTS[0];
2921

@@ -37,9 +29,7 @@ export async function connectDotns(): Promise<ConnectedDotns> {
3729
const substrateAddress = account.address;
3830
const evmAddress = await clientWrapper.getEvmAddress(substrateAddress);
3931

40-
const signer = getPolkadotSigner(account.publicKey, "Sr25519", async (input) =>
41-
account.sign(input),
42-
);
32+
const signer = createSubstrateSigner(account);
4333

4434
return { client, clientWrapper, substrateAddress, evmAddress, signer };
4535
}
@@ -52,9 +42,7 @@ export async function connectBulletin() {
5242
const account = await createAccountFromSource(mnemonic ?? keyUri, mnemonic ? false : true);
5343

5444
const substrateAddress = account.address;
55-
const signer = getPolkadotSigner(account.publicKey, "Sr25519", async (input) =>
56-
account.sign(input),
57-
);
45+
const signer = createSubstrateSigner(account);
5846

5947
const rawClient = createClient(getWsProvider(rpc));
6048
const client = rawClient.getTypedApi(bulletin);

packages/cli/src/utils/contractInteractions.ts

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,32 @@ import type { ReviveClientWrapper } from "../client/polkadotClient";
1515
import { DOT_NODE, OPERATION_TIMEOUT_MILLISECONDS } from "./constants";
1616
import { createTransactionStatusHandler, withTimeout } from "./formatting";
1717

18+
export const UNMAPPED_ORIGIN_REVERT_HINT =
19+
"Contract reverted with empty data. The origin SS58 is likely not mapped on " +
20+
"Asset Hub Revive. Run `dotns account map` (or any signed transaction from " +
21+
"this account), then retry.";
22+
23+
export function isRevertFlag(flags: bigint): boolean {
24+
return (flags & 1n) === 1n;
25+
}
26+
27+
export function buildRevertError(data: Hex, abi: Abi): Error {
28+
if (data === "0x") {
29+
return new Error(UNMAPPED_ORIGIN_REVERT_HINT);
30+
}
31+
32+
let revertReason: string = data;
33+
try {
34+
const decoded = decodeErrorResult({ abi, data });
35+
revertReason = decoded.args
36+
? `${decoded.errorName}(${decoded.args.map(String).join(", ")})`
37+
: decoded.errorName;
38+
} catch {
39+
// Unknown error selector — fall back to raw hex
40+
}
41+
return new Error(`Contract reverted: ${revertReason}`);
42+
}
43+
1844
export async function performContractCall<T>(
1945
clientWrapper: ReviveClientWrapper,
2046
originSubstrateAddress: string,
@@ -39,17 +65,8 @@ export async function performContractCall<T>(
3965
const data = (call?.result?.value?.data ?? "0x") as `0x${string}`;
4066
const flags = (call?.result?.value?.flags ?? 1n) as bigint;
4167

42-
if ((flags & 1n) === 1n) {
43-
let revertReason: string = data;
44-
try {
45-
const decoded = decodeErrorResult({ abi, data });
46-
revertReason = decoded.args
47-
? `${decoded.errorName}(${decoded.args.map(String).join(", ")})`
48-
: decoded.errorName;
49-
} catch {
50-
// Unknown error selector — fall back to raw hex
51-
}
52-
throw new Error(`Contract reverted: ${revertReason}`);
68+
if (isRevertFlag(flags)) {
69+
throw buildRevertError(data, abi);
5370
}
5471

5572
const decoded = decodeFunctionResult({

packages/cli/tests/_helpers/cliHelpers.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ export const TEST_PASSWORD = "test-password";
2121
export const TEST_MNEMONIC =
2222
"bottom drive obey lake curtain smoke basket hold race lonely fit walk";
2323
export const ALICE_KEY_URI = "//Alice";
24+
export const ALICE_SS58 = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY";
25+
export const ALICE_EVM = "0xf24FF3a9CF04c71Dbc94D0b566f7A27B94566cac" as const;
26+
export const BOB_EVM_ADDRESS = "0x0000000000000000000000000000000000000001" as const;
27+
export const PLACEHOLDER_EVM_ADDRESS = "0x000000000000000000000000000000000000dead" as const;
28+
export const TEST_OWNER_EVM_ADDRESS = "0x3Cd0A705a2DC65e5b1E1205896BaA2be8A07c6e0" as const;
2429
export const TEST_ACCOUNT = "default";
2530
export const TEST_TIMEOUT_MS = 120_000;
2631

@@ -195,7 +200,7 @@ export async function createDefaultAccountKeystore(
195200
keystorePassword: string,
196201
accountName: string = "default",
197202
): Promise<{ testMnemonic: string }> {
198-
const testMnemonic = "bottom drive obey lake curtain smoke basket hold race lonely fit walk";
203+
const testMnemonic = TEST_MNEMONIC;
199204

200205
const createResult = await runDotnsCli([
201206
"--keystore-path",
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { createClient } from "polkadot-api";
2+
import { getWsProvider } from "polkadot-api/ws-provider";
3+
import { paseo } from "@polkadot-api/descriptors";
4+
import { Keyring } from "@polkadot/keyring";
5+
import { cryptoWaitReady, mnemonicGenerate } from "@polkadot/util-crypto";
6+
import { createSubstrateSigner } from "../../src/commands/auth";
7+
import { RPC_ENDPOINTS } from "../../src/utils/constants";
8+
import { resolveRpc } from "../../src/cli/env";
9+
import { ALICE_KEY_URI } from "./cliHelpers";
10+
11+
const FUND_AMOUNT_PLANCK = 1_000_000_000n;
12+
13+
export async function generateFreshMnemonic(): Promise<string> {
14+
await cryptoWaitReady();
15+
return mnemonicGenerate();
16+
}
17+
18+
export async function deriveSubstrateAddress(mnemonic: string): Promise<string> {
19+
await cryptoWaitReady();
20+
return new Keyring({ type: "sr25519" }).addFromMnemonic(mnemonic).address;
21+
}
22+
23+
export async function fundAccountFromAlice(recipientSubstrateAddress: string): Promise<void> {
24+
await cryptoWaitReady();
25+
const alice = new Keyring({ type: "sr25519" }).addFromUri(ALICE_KEY_URI);
26+
const signer = createSubstrateSigner(alice);
27+
28+
const rpc = resolveRpc();
29+
const client = createClient(getWsProvider(rpc));
30+
try {
31+
const typedApi = client.getTypedApi(paseo);
32+
const transfer = typedApi.tx.Balances.transfer_keep_alive({
33+
dest: { type: "Id", value: recipientSubstrateAddress },
34+
value: FUND_AMOUNT_PLANCK,
35+
});
36+
37+
await new Promise<void>((resolve, reject) => {
38+
transfer.signSubmitAndWatch(signer).subscribe({
39+
next: (event: any) => {
40+
if (event.type === "finalized") {
41+
if (event.dispatchError) {
42+
reject(new Error(`Funding transfer failed: ${JSON.stringify(event.dispatchError)}`));
43+
return;
44+
}
45+
resolve();
46+
}
47+
},
48+
error: reject,
49+
});
50+
});
51+
} finally {
52+
client.destroy();
53+
}
54+
}
55+
56+
export { RPC_ENDPOINTS };

packages/cli/tests/integration/account/account.test.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,12 @@ import { expect, test } from "bun:test";
22
import {
33
HARNESS_SUCCESS_EXIT_CODE,
44
ALICE_KEY_URI,
5+
ALICE_SS58,
6+
ALICE_EVM,
57
TEST_TIMEOUT_MS,
68
runDotnsCli,
79
} from "../../_helpers/cliHelpers";
810

9-
const ALICE_SS58 = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY";
10-
const ALICE_EVM = "0xf24FF3a9CF04c71Dbc94D0b566f7A27B94566cac";
11-
1211
test(
1312
"account is-mapped with Alice SS58 returns mapped status",
1413
async () => {

packages/cli/tests/integration/bulletin/bulletin.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { promises as fs } from "node:fs";
55
import {
66
createDefaultAccountKeystore,
77
HARNESS_SUCCESS_EXIT_CODE,
8+
ALICE_KEY_URI,
89
runDotnsCli,
910
TEST_ACCOUNT,
1011
TEST_PASSWORD,
@@ -303,7 +304,7 @@ test(
303304
expect(result.combinedOutput).toContain("rpc:");
304305
expect(result.combinedOutput).toContain("transactions:");
305306
expect(result.combinedOutput).toContain("signer:");
306-
expect(result.combinedOutput).toContain("//Alice");
307+
expect(result.combinedOutput).toContain(ALICE_KEY_URI);
307308
},
308309
{ timeout: BULLETIN_TEST_TIMEOUT_MS },
309310
);
@@ -318,7 +319,7 @@ test(
318319
expectSuccessfulAuthorize(result);
319320
expect(result.combinedOutput).toContain("target:");
320321
expect(result.combinedOutput).toContain("signer:");
321-
expect(result.combinedOutput).toContain("//Alice");
322+
expect(result.combinedOutput).toContain(ALICE_KEY_URI);
322323
},
323324
{ timeout: BULLETIN_TEST_TIMEOUT_MS },
324325
);

0 commit comments

Comments
 (0)