Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions typescript/.changeset/fluffy-fishes-decide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@sovereign-sdk/multisig": minor
---

Multisig rework.
- Replace `MultisigTransaction` with payload-agnostic `Multisig` signer state and move transaction finalization into `@sovereign-sdk/web3`.
- Migrate payload serialization to `rollup.multisigSigningBytes(unsignedTx)`, collect signatures with `multisig.addSignature(signature, pubKey)`, and finalize with `multisig.toTransaction(unsignedTx)`.
- See the new code examples in the READMEs for detailed examples.
7 changes: 7 additions & 0 deletions typescript/.changeset/silent-pans-wave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@sovereign-sdk/types": minor
---

Multisig rework.
- `UnsignedTransaction` is now an enum with two variants, representing the bytes serialized for signing.
- Migration: in most cases, `UnsignedTransactionV0` for constructing the payload. `UnsignedTransactionV1` is constructed automatically when using `rollup.multisigSigningBytes(unsignedTx)`.
8 changes: 8 additions & 0 deletions typescript/.changeset/silly-nails-thank.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@sovereign-sdk/web3": minor
---

Multisig rework.

- Align unsigned transaction signing with Rust's versioned `UnsignedTransaction` enum, add first-class standard multisig helpers.
- See the migration notes and README.md in `@sovereign-sdk/multisig` for detailed migration notes on multisigs. This affects both standard and solana-signable rollups.
2 changes: 2 additions & 0 deletions typescript/packages/integration-tests/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@
"devDependencies": {
"@noble/ed25519": "^2.1.0",
"@noble/hashes": "^1.5.0",
"@sovereign-sdk/multisig": "workspace:^",
"@sovereign-sdk/signers": "workspace:^",
"@sovereign-sdk/utils": "workspace:^",
"@sovereign-sdk/web3": "workspace:^",
"@types/node": "^22.7.4",
"bech32": "^2.0.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import { Multisig } from "@sovereign-sdk/multisig";
import { Ed25519Signer, type Signer } from "@sovereign-sdk/signers";
import type { UnsignedTransaction } from "@sovereign-sdk/types";
import { bytesToHex } from "@sovereign-sdk/utils";
import {
DEFAULT_TX_DETAILS,
type StandardRollup,
createStandardRollup,
} from "@sovereign-sdk/web3";
import { beforeAll, describe, expect, it } from "vitest";
import { MultisigTransaction } from "../src";

const testAddress = {
Standard: "sov1lzkjgdaz08su3yevqu6ceywufl35se9f33kztu5cu2spja5hyyf",
Expand All @@ -23,23 +22,20 @@ function generateSigners(count = 5): Signer[] {
const signers = [];

for (let i = 0; i < count; i++) {
const pk = generatePrivateKey();
const signer = new Ed25519Signer(pk);
signers.push(signer);
signers.push(new Ed25519Signer(generatePrivateKey()));
}

return signers;
}

describe("multisig", async () => {
let rollup: StandardRollup<any>;
// gets populated in beforeAll
let chain_id = 0;
let chainId = 0;

beforeAll(async () => {
rollup = await createStandardRollup();
const constants = await rollup.rollup.constants();
chain_id = constants.chain_id;
chainId = constants.chain_id;
});

it("should submit a multisig transaction successfully", async () => {
Expand All @@ -55,27 +51,33 @@ describe("multisig", async () => {
},
},
};
const unsignedTx: UnsignedTransaction<any> = {
const unsignedTx = {
runtime_call,
uniqueness: { nonce: 0 },
details: { ...DEFAULT_TX_DETAILS, chain_id },
details: { ...DEFAULT_TX_DETAILS, chain_id: chainId },
};

const requiredSigners = 3;
const multiSigSigners = generateSigners(requiredSigners);
const allPublicKeyBytes = await Promise.all(
multiSigSigners.map((s) => s.publicKey()),
multiSigSigners.map((signer) => signer.publicKey()),
);
const allPubKeys = allPublicKeyBytes.map(bytesToHex);
const signedTransactions = await Promise.all(
multiSigSigners.map((s) => rollup.signTransaction(unsignedTx, s)),
const multisig = Multisig.fromPubKeys(
allPublicKeyBytes.map(bytesToHex),
requiredSigners,
);

for (const signer of multiSigSigners) {
const signingBytes = await rollup.multisigSigningBytes(unsignedTx, multisig);
multisig.addSignature(
bytesToHex(await signer.sign(signingBytes)),
bytesToHex(await signer.publicKey()),
);
}

const response = await rollup.submitTransaction(
multisig.toTransaction(unsignedTx),
);
const multisig = MultisigTransaction.fromTransactions({
txns: signedTransactions,
minSigners: requiredSigners,
allPubKeys,
});
const response = await rollup.submitTransaction(multisig.asTransaction());

expect(response.status).toEqual("submitted");
});
Expand Down
123 changes: 26 additions & 97 deletions typescript/packages/multisig/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,127 +3,56 @@
[![npm version](https://img.shields.io/npm/v/@sovereign-sdk/multisig.svg)](https://www.npmjs.com/package/@sovereign-sdk/multisig)
[![CI](https://github.com/Sovereign-Labs/sovereign-sdk/actions/workflows/typescript.ci.yml/badge.svg)](https://github.com/Sovereign-Labs/sovereign-sdk/actions/workflows/typescript.ci.yml)

A TypeScript library for creating and managing multisig transactions on Sovereign SDK rollups.
Payload-agnostic multisig signer state for Sovereign SDK applications.

Use `@sovereign-sdk/multisig` when you want to collect signatures outside the rollup client and finalize them into a standard `TransactionV1` once the threshold is met.

## Installation

```bash
npm install @sovereign-sdk/multisig
```

## Features

- **Multisig Transaction Management**: Create and manage transactions requiring multiple signatures
- **Threshold Signatures**: Configurable minimum signature requirements (M-of-N)
- **Signature Collection**: Collect signatures from multiple parties before submission
- **Transaction Conversion**: Convert between V0 (single-sig) and V1 (multisig) transaction formats
- **Safety Checks**: Validates transaction consistency and prevents replay attacks

## Usage

### Basic Multisig Workflow

```typescript
import { StandardRollup, createStandardRollup } from "@sovereign-sdk/web3";
import { MultisigTransaction } from "@sovereign-sdk/multisig";
import type { UnsignedTransaction } from "@sovereign-sdk/types";
import { Multisig } from "@sovereign-sdk/multisig";
import { createStandardRollup } from "@sovereign-sdk/web3";
import type { UnsignedTransactionV0 } from "@sovereign-sdk/types";
import { bytesToHex } from "@sovereign-sdk/utils";

// 1. Create an unsigned transaction
const unsignedTx: UnsignedTransaction<YourRuntimeCall> = {
const rollup = await createStandardRollup<YourRuntimeCall>();

const unsignedTx: UnsignedTransactionV0<YourRuntimeCall> = {
runtime_call: {
// Your rollup-specific call data
value_setter: { set_value: { value: 100, gas: null } },
},
uniqueness: { nonce: 1 }, // Must be nonce-based for multisig
uniqueness: { nonce: 1 },
details: {
max_priority_fee_bips: 100,
max_priority_fee_bips: 0,
max_fee: "1000000",
gas_limit: null,
chain_id: 4321,
},
};

// 2. Initialize multisig with all participant public keys
const allPubKeys = ["0xpubkey1", "0xpubkey2", "0xpubkey3"];
const minSigners = 2; // 2-of-3 multisig
const multisig = Multisig.fromPubKeys(
["pubkey1hex", "pubkey2hex", "pubkey3hex"],
2,
);

const multisig = MultisigTransaction.empty(unsignedTx, minSigners, allPubKeys);
const signingBytes = await rollup.multisigSigningBytes(unsignedTx, multisig);
multisig.addSignature(
bytesToHex(await signer1.sign(signingBytes)),
bytesToHex(await signer1.publicKey()),
);

// 3. Add signatures as they're collected
multisig.addSignature("0xsignature1", "0xpubkey1");
multisig.addSignature("0xsignature2", "0xpubkey2");
multisig.addSignature(
bytesToHex(await signer2.sign(signingBytes)),
bytesToHex(await signer2.publicKey()),
);

// 4. Check if ready for submission
if (multisig.isComplete) {
// Convert to rollup transaction for submission
const finalTx = multisig.asTransaction();
const rollup = await createStandardRollup();
// Submit the transaction to the rollup
const result = await rollup.submitTransaction(finalTx);
console.log("Multisig transaction submitted", result);
await rollup.submitTransaction(multisig.toTransaction(unsignedTx));
}
```

### Creating from Existing Signed Transactions

```typescript
import { MultisigTransaction } from '@sovereign-sdk/multisig';
import type { Transaction } from '@sovereign-sdk/types';

// If you have individual V0 transactions from different signers
const signedTxns: Transaction<YourRuntimeCall>[] = [
{
V0: {
pub_key: "0xpubkey1",
signature: "0xsignature1",
runtime_call: /* same call data */,
uniqueness: { nonce: 1 },
details: /* same details */,
}
},
{
V0: {
pub_key: "0xpubkey2",
signature: "0xsignature2",
runtime_call: /* same call data */,
uniqueness: { nonce: 1 },
details: /* same details */,
}
}
];

// Create multisig from existing transactions
const multisig = MultisigTransaction.fromTransactions({
txns: signedTxns,
minSigners: 2,
allPubKeys: ["0xpubkey1", "0xpubkey2", "0xpubkey3"]
});
```

### Adding Individual Signed Transactions

```typescript
// Add a complete signed transaction to the multisig
const signedTx: Transaction<YourRuntimeCall> = {
V0: {
pub_key: "0xpubkey3",
signature: "0xsignature3",
// ... same transaction data
},
};

multisig.addSignedTransaction(signedTx);

// Now check if complete and submit
if (multisig.isComplete) {
const finalTx = multisig.asTransaction();
// Submit to rollup...
}
```

## Important Notes

- **Nonce-based transactions only**: Multisig currently only supports transactions with `{ nonce: number }` uniqueness
- **Transaction consistency**: All signatures must be for the exact same unsigned transaction
- **Public key validation**: Public keys must be known members of the multisig and can only sign once

3 changes: 0 additions & 3 deletions typescript/packages/multisig/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
"fix": "biome check --write",
"lint": "biome lint",
"test": "vitest run **/*.test.ts",
"test:integration": "vitest run **/*.integration-test.ts",
"typecheck": "tsc --noEmit"
},
"repository": "github:Sovereign-Labs/sovereign-sdk",
Expand All @@ -25,7 +24,6 @@
"types": "./dist/index.d.ts",
"devDependencies": {
"@sovereign-sdk/types": "workspace:^",
"@sovereign-sdk/web3": "workspace:^",
"tsup": "^8.3.0",
"typescript": "^5.6.3",
"vitest": "4.0.7"
Expand All @@ -39,7 +37,6 @@
},
"dependencies": {
"@noble/hashes": "^1.5.0",
"@sovereign-sdk/signers": "workspace:^",
"@sovereign-sdk/utils": "workspace:^",
"borsh": "0.7.0"
}
Expand Down
Loading
Loading