Skip to content

Commit e1252eb

Browse files
authored
feat: add dry run and migrate key management to common (#99)
1 parent a24fdf7 commit e1252eb

File tree

16 files changed

+529
-124
lines changed

16 files changed

+529
-124
lines changed

packages/common/package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,10 @@
3838
"test:watch": "vitest"
3939
},
4040
"dependencies": {
41-
"polkadot-api": "^1.9.13"
41+
"polkadot-api": "^1.9.13",
42+
"@polkadot-labs/hdkd": "^0.0.13",
43+
"@polkadot-labs/hdkd-helpers": "^0.0.13",
44+
"@subsquid/ss58": "^2.0.2"
4245
},
4346
"devDependencies": {
4447
"@babel/plugin-syntax-import-attributes": "^7.26.0",
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { ed25519CreateDerive, sr25519CreateDerive } from "@polkadot-labs/hdkd"
2+
import { entropyToMiniSecret, mnemonicToEntropy } from "@polkadot-labs/hdkd-helpers"
3+
import * as ss58 from "@subsquid/ss58"
4+
import type { PolkadotSigner } from "polkadot-api/signer"
5+
import { getPolkadotSigner } from "polkadot-api/signer"
6+
7+
import { getAllSupportedChains, getChainById } from "../chains/chains"
8+
import type { AgentConfig } from "../types"
9+
10+
/**
11+
* Convert a public key (Uint8Array) to a Substrate address
12+
* @param publicKey - The public key as Uint8Array (32 bytes)
13+
* @param chainId - The chain ID to get the correct SS58 prefix
14+
* @returns The SS58-encoded address string
15+
*/
16+
export function publicKeyToAddress(publicKey: Uint8Array, chainId: string = "polkadot"): string {
17+
const chain = getChainById(chainId, getAllSupportedChains())
18+
return ss58.codec(chain.prefix).encode(publicKey)
19+
}
20+
21+
/**
22+
* Derive and convert address from mini secret
23+
*
24+
* @param miniSecret - The mini secret as Uint8Array (32 bytes)
25+
* @param keyType - The cryptographic key type ("Sr25519" or "Ed25519")
26+
* @param derivationPath - The BIP44 derivation path (e.g., "//0", "//hard/soft")
27+
* @param chainId - The target chain ID for address encoding (default: "polkadot")
28+
* @returns The SS58-encoded address string for the specified chain
29+
*
30+
*/
31+
export function deriveAndConvertAddress(
32+
miniSecret: Uint8Array,
33+
keyType: "Sr25519" | "Ed25519",
34+
derivationPath: string,
35+
chainId: string = "polkadot"
36+
): string {
37+
const keypair = getKeypair(miniSecret, keyType, derivationPath)
38+
return publicKeyToAddress(keypair.publicKey, chainId)
39+
}
40+
41+
/**
42+
* Generate mini secret from agent config
43+
* @param config - The agent configuration
44+
* @returns The mini secret as Uint8Array
45+
*/
46+
export function generateMiniSecret(config: AgentConfig): Uint8Array {
47+
if (!config.mnemonic && !config.privateKey) {
48+
throw new Error("Missing mnemonic phrase or privateKey")
49+
}
50+
51+
if (config.mnemonic && config.privateKey) {
52+
throw new Error("Cannot provide both mnemonic phrase and privateKey")
53+
}
54+
55+
if (config.mnemonic) {
56+
const entropy = mnemonicToEntropy(config.mnemonic)
57+
return entropyToMiniSecret(entropy)
58+
} else if (config.privateKey) {
59+
const privateKeyHex = config.privateKey.startsWith("0x")
60+
? config.privateKey.slice(2)
61+
: config.privateKey
62+
63+
return new Uint8Array(privateKeyHex.match(/.{1,2}/g)!.map(byte => parseInt(byte, 16)))
64+
} else {
65+
throw new Error("No valid wallet source found")
66+
}
67+
}
68+
69+
/**
70+
* Get keypair from mini secret and derivation path
71+
* @param miniSecret - The mini secret
72+
* @param keyType - The key type
73+
* @param derivationPath - The derivation path
74+
* @returns The derived keypair
75+
*/
76+
export function getKeypair(
77+
miniSecret: Uint8Array,
78+
keyType: "Sr25519" | "Ed25519",
79+
derivationPath: string = ""
80+
) {
81+
const derive =
82+
keyType === "Sr25519" ? sr25519CreateDerive(miniSecret) : ed25519CreateDerive(miniSecret)
83+
84+
return derive(derivationPath)
85+
}
86+
87+
export function getSigner(
88+
miniSecret: Uint8Array,
89+
keyType: "Sr25519" | "Ed25519",
90+
derivationPath: string = ""
91+
): PolkadotSigner {
92+
if (keyType === "Sr25519") {
93+
const signer = getPolkadotSigner(
94+
getKeypair(miniSecret, keyType, derivationPath).publicKey,
95+
keyType,
96+
input => getKeypair(miniSecret, keyType, derivationPath).sign(input)
97+
)
98+
99+
return signer
100+
} else {
101+
const signer = getPolkadotSigner(
102+
getKeypair(miniSecret, keyType, derivationPath).publicKey,
103+
keyType,
104+
input => getKeypair(miniSecret, keyType, derivationPath).sign(input)
105+
)
106+
return signer
107+
}
108+
}

packages/common/src/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export * from "./crypto"
12
export * from "./formatBalance"
23
export * from "./isAssetHub"
34
export * from "./isParachain"
Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,54 @@
1-
import type { UnsafeTransactionType } from "@polkadot-agent-kit/common"
21
import { type Api, type KnownChainId } from "@polkadot-agent-kit/common"
2+
3+
import type { TxResult } from "../../types/transaction"
4+
import { dryRunCall, type DryRunResult } from "../../utils/dryRun"
35
/**
4-
* Creates a transfer call for native assets
5-
* @param api - The API instance to use for the transfer
6-
* @param to - The recipient address
7-
* @param amount - The amount to transfer
8-
* @returns The transfer call
6+
* Creates a transfer call for native assets with comprehensive dry run validation
7+
*
8+
* @param api - The API instance for the source chain
9+
* @param from - The sender's address (SS58 format)
10+
* @param to - The recipient's address (SS58 format)
11+
* @param amount - The amount to transfer (in base units as BigInt)
12+
* @returns Promise resolving to TxResult with success/failure information
13+
*
14+
* @throws Error If the dry run indicates the transaction would fail
915
*/
10-
export const transferNativeCall = (
16+
17+
export const transferNativeCall = async (
1118
api: Api<KnownChainId>,
19+
from: string,
1220
to: string,
1321
amount: bigint
14-
): UnsafeTransactionType => {
15-
return api.tx.Balances.transfer_keep_alive({
22+
): Promise<TxResult> => {
23+
const tx = api.tx.Balances.transfer_keep_alive({
1624
dest: {
1725
type: "Id",
1826
value: to
1927
},
2028
value: amount
2129
})
30+
const dryRunResult: DryRunResult = await dryRunCall(api, from, tx)
31+
32+
if (dryRunResult.value?.execution_result?.success) {
33+
return {
34+
success: true,
35+
transaction: tx
36+
}
37+
} else {
38+
const executionError = dryRunResult.value?.execution_result?.value
39+
40+
if (executionError?.error) {
41+
const { error } = executionError
42+
43+
return {
44+
success: false,
45+
error: `${error.type} error: ${error.value?.type || "Unknown error"}`
46+
}
47+
}
48+
49+
return {
50+
success: false,
51+
error: "Unknown error"
52+
}
53+
}
2254
}
Lines changed: 62 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { getNativeAssets } from "@paraspell/assets"
2-
import type { TDestination, TNodeDotKsmWithRelayChains, TPapiTransaction } from "@paraspell/sdk"
2+
import type { TDestination, TNodeDotKsmWithRelayChains } from "@paraspell/sdk"
33
import { Builder } from "@paraspell/sdk"
44
import { parseUnits } from "@polkadot-agent-kit/common"
5+
6+
import type { XcmTransferResult } from "../../types/xcm"
57
/**
68
* Builds an XCM transaction to transfer a native asset from one chain to another.
79
*
@@ -14,17 +16,7 @@ import { parseUnits } from "@polkadot-agent-kit/common"
1416
* @param from - The sender's address on the source chain
1517
* @param to - The recipient's address on the destination chain
1618
* @param amount - The amount of the native asset to transfer (as bigint, in base units)
17-
* @returns A Promise resolving to a TPapiTransaction object representing the unsigned XCM transaction
18-
*
19-
* @example
20-
* const tx = await xcmTransferNativeAsset(
21-
* 'polkadot',
22-
* 'hydra',
23-
* 'senderAddress',
24-
* 'recipientAddress',
25-
* 10000000000n
26-
* )
27-
* // tx can then be signed and submitted using the appropriate transaction handler
19+
* @returns Promise resolving to XcmTransferResult with detailed success/failure information
2820
*/
2921

3022
export const xcmTransferNativeAsset = async (
@@ -33,31 +25,69 @@ export const xcmTransferNativeAsset = async (
3325
from: string,
3426
to: string,
3527
amount: string
36-
): Promise<TPapiTransaction> => {
37-
const nativeSymbol = getNativeAssets(srcChain as TNodeDotKsmWithRelayChains)
38-
const decimals = nativeSymbol[0].decimals || 10
39-
const parsedAmount = parseUnits(amount, decimals)
40-
41-
// Dry run the XCM transfer native token
42-
const dryRunTx = await Builder()
43-
.from(srcChain as TNodeDotKsmWithRelayChains)
44-
.senderAddress(from)
45-
.to(destChain as TDestination)
46-
.currency({ symbol: nativeSymbol[0].symbol, amount: parsedAmount })
47-
.address(to)
48-
.dryRun()
49-
if (dryRunTx.origin?.success && dryRunTx.destination?.success) {
50-
// XCM transfer native tokken
51-
const tx = await Builder()
28+
): Promise<XcmTransferResult> => {
29+
try {
30+
const nativeSymbol = getNativeAssets(srcChain as TNodeDotKsmWithRelayChains)
31+
32+
const decimals = nativeSymbol[0].decimals || 10
33+
const parsedAmount = parseUnits(amount, decimals)
34+
35+
// Dry run the XCM transfer native token
36+
const dryRunTx = await Builder()
5237
.from(srcChain as TNodeDotKsmWithRelayChains)
5338
.senderAddress(from)
5439
.to(destChain as TDestination)
5540
.currency({ symbol: nativeSymbol[0].symbol, amount: parsedAmount })
5641
.address(to)
57-
.build()
42+
.dryRun()
43+
44+
const dryRunDetails = {
45+
originSuccess: dryRunTx.origin?.success || false,
46+
destinationSuccess: dryRunTx.destination?.success || false,
47+
originError: dryRunTx.origin?.success ? undefined : dryRunTx.origin?.failureReason,
48+
destinationError: dryRunTx.destination?.success
49+
? undefined
50+
: dryRunTx.destination?.failureReason
51+
}
52+
53+
if (dryRunTx.origin?.success && dryRunTx.destination?.success) {
54+
// Build the actual XCM transaction
55+
const tx = await Builder()
56+
.from(srcChain as TNodeDotKsmWithRelayChains)
57+
.senderAddress(from)
58+
.to(destChain as TDestination)
59+
.currency({ symbol: nativeSymbol[0].symbol, amount: parsedAmount })
60+
.address(to)
61+
.build()
62+
63+
return {
64+
success: true,
65+
transaction: tx,
66+
dryRunDetails
67+
}
68+
} else {
69+
const errorDetails = []
70+
if (!dryRunTx.origin?.success) {
71+
errorDetails.push(
72+
`Origin chain error: ${dryRunTx.origin?.failureReason || "Unknown error"}`
73+
)
74+
}
75+
if (!dryRunTx.destination?.success) {
76+
errorDetails.push(
77+
`Destination chain error: ${dryRunTx.destination?.failureReason || "Unknown error"}`
78+
)
79+
}
5880

59-
return tx
60-
} else {
61-
throw Error("XCM dry run failed")
81+
return {
82+
success: false,
83+
error: `XCM dry run failed: ${errorDetails.join("; ")}`,
84+
dryRunDetails
85+
}
86+
}
87+
} catch (error) {
88+
return {
89+
success: false,
90+
error: `XCM transaction dry run failed: ${error instanceof Error ? error.message : String(error)}`
91+
}
6292
}
6393
}

packages/core/src/types/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * from "./identity"
22
export * from "./transaction"
3+
export * from "./xcm"

packages/core/src/types/transaction.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,9 @@ export function isTxWithPolkadotSigner(
3636
typeof options.signer.signBytes === "function"
3737
)
3838
}
39+
40+
export interface TxResult {
41+
success: boolean
42+
transaction?: UnsafeTransactionType
43+
error?: string
44+
}

packages/core/src/types/xcm.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import type { TPapiTransaction } from "@paraspell/sdk"
2+
3+
export interface XcmTransferResult {
4+
success: boolean
5+
transaction?: TPapiTransaction
6+
error?: string
7+
dryRunDetails?: {
8+
originSuccess: boolean
9+
destinationSuccess: boolean
10+
originError?: string
11+
destinationError?: string
12+
}
13+
}

0 commit comments

Comments
 (0)