Skip to content

Commit 8a0cf36

Browse files
committed
feat: add multisig sdk
1 parent 1bacd27 commit 8a0cf36

File tree

10 files changed

+419
-0
lines changed

10 files changed

+419
-0
lines changed

packages/common-utils/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## Unreleased
44

5+
## Added
6+
7+
- Property `waited` in `AsyncTransaction` to get a `Promise<Transaction>`
8+
59
## 0.0.1-1 2025-02-17
610

711
Initial release

packages/common-utils/src/asyncTransaction.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export type AsyncTransaction<
1212
> & {
1313
decodedCall: Promise<Transaction<Arg, Pallet, Name, Asset>["decodedCall"]>
1414
getEncodedData: () => Promise<Binary>
15+
waited: Promise<Transaction<Arg, Pallet, Name, Asset>>
1516
}
1617

1718
export const wrapAsyncTx = <
@@ -23,6 +24,12 @@ export const wrapAsyncTx = <
2324
fn: () => Promise<Transaction<Arg, Pallet, Name, Asset>>,
2425
): AsyncTransaction<Arg, Pallet, Name, Asset> => {
2526
const promise = fn()
27+
28+
// Prevent some runtimes from terminating for an uncaught exception
29+
promise.catch((ex) => {
30+
console.error(ex)
31+
})
32+
2633
return {
2734
sign: (...args) => promise.then((tx) => tx.sign(...args)),
2835
signSubmitAndWatch: (...args) =>
@@ -34,5 +41,6 @@ export const wrapAsyncTx = <
3441
promise.then((tx) => tx.getPaymentInfo(...args)),
3542
decodedCall: promise.then((tx) => tx.decodedCall),
3643
getEncodedData: () => promise.then((tx) => tx.getEncodedData()),
44+
waited: promise,
3745
}
3846
}

packages/sdk-multisig/CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Changelog
2+
3+
## Unreleased
4+
5+
## 0.1.0 2025-07-31
6+
7+
Initial release

packages/sdk-multisig/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# @polkadot-api/sdk-multisig

packages/sdk-multisig/package.json

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
{
2+
"name": "@polkadot-api/sdk-multisig",
3+
"version": "0.1.0",
4+
"sideEffects": false,
5+
"author": "Victor Oliva (https://github.com/voliva)",
6+
"repository": {
7+
"type": "git",
8+
"url": "git+https://github.com/polkadot-api/papi-sdks.git"
9+
},
10+
"exports": {
11+
".": {
12+
"node": {
13+
"production": {
14+
"import": "./dist/esm/index.mjs",
15+
"require": "./dist/index.js",
16+
"default": "./dist/index.js"
17+
},
18+
"import": "./dist/esm/index.mjs",
19+
"require": "./dist/index.js",
20+
"default": "./dist/index.js"
21+
},
22+
"module": "./dist/esm/index.mjs",
23+
"import": "./dist/esm/index.mjs",
24+
"require": "./dist/index.js",
25+
"default": "./dist/index.js"
26+
},
27+
"./package.json": "./package.json"
28+
},
29+
"main": "./dist/index.js",
30+
"module": "./dist/esm/index.mjs",
31+
"browser": "./dist/esm/index.mjs",
32+
"types": "./dist/index.d.ts",
33+
"files": [
34+
"dist"
35+
],
36+
"scripts": {
37+
"build": "rollup -c ../../rollup.config.js",
38+
"lint": "prettier --check README.md \"src/**/*.{js,jsx,ts,tsx,json,md}\"",
39+
"format": "prettier --write README.md \"src/**/*.{js,jsx,ts,tsx,json,md}\"",
40+
"prepack": "pnpm run build"
41+
},
42+
"license": "MIT",
43+
"dependencies": {
44+
"@polkadot-api/common-sdk-utils": "workspace:*",
45+
"@polkadot-api/meta-signers": "^0.1.8",
46+
"@polkadot-api/substrate-bindings": "^0.14.0"
47+
},
48+
"peerDependencies": {
49+
"polkadot-api": ">=1.14.1",
50+
"rxjs": ">=7.8.0"
51+
},
52+
"devDependencies": {
53+
"polkadot-api": "^1.14.1",
54+
"rxjs": "^7.8.2"
55+
}
56+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { SdkDefinition } from "@polkadot-api/common-sdk-utils"
2+
import {
3+
ApisTypedef,
4+
Binary,
5+
FixedSizeBinary,
6+
PalletsTypedef,
7+
RuntimeDescriptor,
8+
StorageDescriptor,
9+
TxCallData,
10+
TxDescriptor,
11+
TypedApi,
12+
} from "polkadot-api"
13+
14+
type MultisigSdkPallets<Addr> = PalletsTypedef<
15+
{
16+
Multisig: {
17+
/**
18+
* The set of open multisig operations.
19+
*/
20+
Multisigs: StorageDescriptor<
21+
[Addr, FixedSizeBinary<32>],
22+
{
23+
when: {
24+
height: number
25+
index: number
26+
}
27+
approvals: Array<Addr>
28+
},
29+
true,
30+
never
31+
>
32+
}
33+
},
34+
{
35+
Multisig: {
36+
as_multi_threshold_1: TxDescriptor<{
37+
other_signatories: Addr[]
38+
call: TxCallData
39+
}>
40+
as_multi: TxDescriptor<{
41+
threshold: number
42+
other_signatories: Addr[]
43+
maybe_timepoint?:
44+
| {
45+
index: number
46+
height: number
47+
}
48+
| undefined
49+
call: TxCallData
50+
max_weight: {
51+
ref_time: bigint
52+
proof_size: bigint
53+
}
54+
}>
55+
approve_as_multi: TxDescriptor<{
56+
threshold: number
57+
other_signatories: Addr[]
58+
maybe_timepoint?:
59+
| {
60+
index: number
61+
height: number
62+
}
63+
| undefined
64+
call_hash: FixedSizeBinary<32>
65+
max_weight: {
66+
ref_time: bigint
67+
proof_size: bigint
68+
}
69+
}>
70+
}
71+
},
72+
{},
73+
{},
74+
{},
75+
{}
76+
>
77+
78+
type MultisigSdkApis = ApisTypedef<{
79+
TransactionPaymentApi: {
80+
query_info: RuntimeDescriptor<
81+
[uxt: Binary, len: number],
82+
{
83+
weight: {
84+
ref_time: bigint
85+
proof_size: bigint
86+
}
87+
}
88+
>
89+
}
90+
}>
91+
92+
type MultisigSdkDefinition<Addr> = SdkDefinition<
93+
MultisigSdkPallets<Addr>,
94+
MultisigSdkApis
95+
>
96+
export type MultisigSdkTypedApi<Addr> = TypedApi<MultisigSdkDefinition<Addr>>
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
import { wrapAsyncTx } from "@polkadot-api/common-sdk-utils"
2+
import {
3+
Binary,
4+
Blake2256,
5+
getMultisigAccountId,
6+
getSs58AddressInfo,
7+
HexString,
8+
sortMultisigSignatories,
9+
SS58String,
10+
} from "@polkadot-api/substrate-bindings"
11+
import { AccountId, Transaction } from "polkadot-api"
12+
import { fromHex, toHex } from "polkadot-api/utils"
13+
import { MultisigSdkTypedApi } from "./descriptors"
14+
import { MultisigSdk, MultisigTxOptions } from "./sdk-types"
15+
16+
const defaultMultisigTxOptions: MultisigTxOptions<unknown> = {
17+
method: (approvals, threshold) =>
18+
approvals.length === threshold - 1 ? "as_multi" : "approve_as_multi",
19+
}
20+
21+
export const createMultisigSdk = <Addr extends SS58String | HexString>(
22+
typedApi: MultisigSdkTypedApi<Addr>,
23+
): MultisigSdk<Addr> => {
24+
const toSS58 = AccountId().dec
25+
26+
const getMultisigTx: MultisigSdk<Addr>["getMultisigTx"] = (
27+
multisig,
28+
signatory,
29+
tx,
30+
options,
31+
) => {
32+
options = {
33+
...defaultMultisigTxOptions,
34+
...options,
35+
}
36+
37+
const toAddress = (value: Uint8Array): Addr => {
38+
if (multisig.signatories[0].startsWith("0x")) {
39+
return toHex(value) as Addr
40+
}
41+
return toSS58(value) as Addr
42+
}
43+
44+
const pubKeys = sortMultisigSignatories(
45+
multisig.signatories.map(getPublicKey),
46+
)
47+
const multisigId = getMultisigAccountId({
48+
threshold: multisig.threshold,
49+
signatories: pubKeys,
50+
})
51+
52+
const signatoryId = getPublicKey(signatory)
53+
const otherSignatories = pubKeys.filter(
54+
(addr) => !u8ArrEq(addr, signatoryId),
55+
)
56+
if (otherSignatories.length === multisig.signatories.length) {
57+
throw new Error("Signer is not one of the signatories of the multisig")
58+
}
59+
60+
return wrapAsyncTx(async () => {
61+
if (multisig.threshold === 1) {
62+
return typedApi.tx.Multisig.as_multi_threshold_1({
63+
other_signatories: otherSignatories.map(toAddress),
64+
call: tx.decodedCall,
65+
})
66+
}
67+
68+
const callHashPromise = tx
69+
.getEncodedData()
70+
.then((callData) => Blake2256(callData.asBytes()))
71+
const [multisigInfo, weightInfo, callHash] = await Promise.all([
72+
callHashPromise.then((callHash) => {
73+
return typedApi.query.Multisig.Multisigs.getValue(
74+
toAddress(multisigId),
75+
Binary.fromBytes(callHash),
76+
)
77+
}),
78+
tx.getPaymentInfo(signatoryId),
79+
callHashPromise,
80+
])
81+
82+
if (
83+
multisigInfo?.approvals.some((approval) =>
84+
u8ArrEq(getPublicKey(approval), signatoryId),
85+
)
86+
) {
87+
throw new Error("Multisig call already approved by signer")
88+
}
89+
90+
const method = options.method(
91+
multisigInfo?.approvals ?? [],
92+
multisig.threshold,
93+
)
94+
95+
const commonPayload = {
96+
threshold: multisig.threshold,
97+
other_signatories: otherSignatories.map(toAddress),
98+
max_weight: weightInfo.weight,
99+
maybe_timepoint: multisigInfo?.when,
100+
}
101+
102+
const wrappedTx: Transaction<any, any, any, any> =
103+
method === "approve_as_multi"
104+
? typedApi.tx.Multisig.approve_as_multi({
105+
...commonPayload,
106+
call_hash: Binary.fromBytes(callHash),
107+
})
108+
: typedApi.tx.Multisig.as_multi({
109+
...commonPayload,
110+
call: tx.decodedCall,
111+
})
112+
113+
return wrappedTx
114+
})
115+
}
116+
117+
return {
118+
getMultisigTx,
119+
getMultisigSigner(multisig, signer, options) {
120+
const pubKeys = sortMultisigSignatories(
121+
multisig.signatories.map(getPublicKey),
122+
)
123+
const multisigId = getMultisigAccountId({
124+
threshold: multisig.threshold,
125+
signatories: pubKeys,
126+
})
127+
128+
const toAddress = (value: Uint8Array): Addr => {
129+
if (multisig.signatories[0].startsWith("0x")) {
130+
return toHex(value) as Addr
131+
}
132+
return toSS58(value) as Addr
133+
}
134+
135+
const signerId =
136+
"accountId" in signer
137+
? (signer.accountId as Uint8Array)
138+
: signer.publicKey
139+
140+
return {
141+
publicKey: signer.publicKey,
142+
accountId: multisigId,
143+
signBytes() {
144+
throw new Error("Raw bytes can't be signed with a multisig")
145+
},
146+
async signTx(
147+
callData,
148+
signedExtensions,
149+
metadata,
150+
atBlockNumber,
151+
hasher,
152+
) {
153+
const tx = await typedApi.txFromCallData(Binary.fromBytes(callData))
154+
const wrappedTx = getMultisigTx(
155+
multisig,
156+
toAddress(signerId),
157+
tx,
158+
options,
159+
)
160+
161+
return signer.signTx(
162+
(await wrappedTx.getEncodedData()).asBytes(),
163+
signedExtensions,
164+
metadata,
165+
atBlockNumber,
166+
hasher,
167+
)
168+
},
169+
}
170+
},
171+
}
172+
}
173+
174+
const u8ArrEq = (a: Uint8Array, b: Uint8Array) => {
175+
if (a.length !== b.length) return false
176+
return a.every((v, i) => v === b[i])
177+
}
178+
179+
const getPublicKey = (addr: SS58String | HexString) => {
180+
if (addr.startsWith("0x")) {
181+
return fromHex(addr)
182+
}
183+
const info = getSs58AddressInfo(addr)
184+
if (!info.isValid) {
185+
throw new Error(`Invalid SS58 address ${addr}`)
186+
}
187+
return info.publicKey
188+
}

0 commit comments

Comments
 (0)