diff --git a/typescript/.changeset/fluffy-fishes-decide.md b/typescript/.changeset/fluffy-fishes-decide.md new file mode 100644 index 0000000000..8d2f18239c --- /dev/null +++ b/typescript/.changeset/fluffy-fishes-decide.md @@ -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. diff --git a/typescript/.changeset/silent-pans-wave.md b/typescript/.changeset/silent-pans-wave.md new file mode 100644 index 0000000000..e33691f880 --- /dev/null +++ b/typescript/.changeset/silent-pans-wave.md @@ -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)`. diff --git a/typescript/.changeset/silly-nails-thank.md b/typescript/.changeset/silly-nails-thank.md new file mode 100644 index 0000000000..092c57f98d --- /dev/null +++ b/typescript/.changeset/silly-nails-thank.md @@ -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. diff --git a/typescript/packages/integration-tests/package.json b/typescript/packages/integration-tests/package.json index d83a9a3707..d68c99e64f 100644 --- a/typescript/packages/integration-tests/package.json +++ b/typescript/packages/integration-tests/package.json @@ -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", diff --git a/typescript/packages/multisig/tests/multisig.integration-test.ts b/typescript/packages/integration-tests/tests/multisig.integration-test.ts similarity index 63% rename from typescript/packages/multisig/tests/multisig.integration-test.ts rename to typescript/packages/integration-tests/tests/multisig.integration-test.ts index 52cd33be00..ab50e4d417 100644 --- a/typescript/packages/multisig/tests/multisig.integration-test.ts +++ b/typescript/packages/integration-tests/tests/multisig.integration-test.ts @@ -1,5 +1,5 @@ +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, @@ -7,7 +7,6 @@ import { createStandardRollup, } from "@sovereign-sdk/web3"; import { beforeAll, describe, expect, it } from "vitest"; -import { MultisigTransaction } from "../src"; const testAddress = { Standard: "sov1lzkjgdaz08su3yevqu6ceywufl35se9f33kztu5cu2spja5hyyf", @@ -23,9 +22,7 @@ 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; @@ -33,13 +30,12 @@ function generateSigners(count = 5): Signer[] { describe("multisig", async () => { let rollup: StandardRollup; - // 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 () => { @@ -55,27 +51,33 @@ describe("multisig", async () => { }, }, }; - const unsignedTx: UnsignedTransaction = { + 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"); }); diff --git a/typescript/packages/multisig/README.md b/typescript/packages/multisig/README.md index 49eaa6556a..39a9a641d1 100644 --- a/typescript/packages/multisig/README.md +++ b/typescript/packages/multisig/README.md @@ -3,7 +3,9 @@ [![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 @@ -11,119 +13,46 @@ A TypeScript library for creating and managing multisig transactions on Sovereig 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 = { +const rollup = await createStandardRollup(); + +const unsignedTx: UnsignedTransactionV0 = { 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[] = [ - { - 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 = { - 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 - diff --git a/typescript/packages/multisig/package.json b/typescript/packages/multisig/package.json index d3b24e6e19..15a1c15728 100644 --- a/typescript/packages/multisig/package.json +++ b/typescript/packages/multisig/package.json @@ -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", @@ -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" @@ -39,7 +37,6 @@ }, "dependencies": { "@noble/hashes": "^1.5.0", - "@sovereign-sdk/signers": "workspace:^", "@sovereign-sdk/utils": "workspace:^", "borsh": "0.7.0" } diff --git a/typescript/packages/multisig/src/index.test.ts b/typescript/packages/multisig/src/index.test.ts index 0c645ecda8..d27bac3942 100644 --- a/typescript/packages/multisig/src/index.test.ts +++ b/typescript/packages/multisig/src/index.test.ts @@ -1,262 +1,219 @@ -import type { - Transaction, - TransactionV0, - TransactionV1, - UnsignedTransaction, -} from "@sovereign-sdk/types"; import { bytesToHex } from "@sovereign-sdk/utils"; import { describe, expect, it } from "vitest"; import { InvalidMultisigParameterError, - InvalidTransactionVersionError, - MultisigTransaction, - TransactionMismatchError, + MAX_SIGNERS, + Multisig, + type MultisigParams, } from "./index"; -const createUnsignedTx = (nonce = 1): UnsignedTransaction => ({ - runtime_call: "test_call", - uniqueness: { nonce }, - details: { - max_priority_fee_bips: 100, - max_fee: "1000", - gas_limit: null, - chain_id: 1, - }, +const pubkey1 = + "33dd646d7c43830b52289c4f277d3a5a26b3ab0a10ee05c871cb654bc046e545"; +const pubkey2 = + "8c7788ad88084f12ce1556703b33e673c5e450eec793c1e8e1a55b1140b4dfb8"; +const pubkey3 = + "74fc1e9c39b21173ef47c1cdfb37158764486c8b8ba81d8f29c6dd77800cd57f"; + +const createParams = (overrides?: Partial): MultisigParams => ({ + signatures: [], + unusedPubKeys: [pubkey1, pubkey2, pubkey3], + minSigners: 2, + ...overrides, }); -const createTransactionV0 = ( - unsignedTx: UnsignedTransaction, - pubKey: string, - signature: string, -): TransactionV0 => ({ - V0: { - pub_key: pubKey, - signature, - ...unsignedTx, - }, -}); - -describe("MultisigTransaction", () => { - describe("fromTransactions", () => { - it("should require nonce based transactions", () => { - const unsignedTx: UnsignedTransaction = { - runtime_call: "test_call", - uniqueness: { generation: 1 }, - details: { - max_priority_fee_bips: 100, - max_fee: "1000", - gas_limit: null, - chain_id: 1, - }, - }; - const tx = createTransactionV0(unsignedTx, "pubkey1", "sig1"); - - expect(() => - MultisigTransaction.fromTransactions({ - txns: [tx], - minSigners: 1, - allPubKeys: ["pubkey1"], - }), +describe("Multisig", () => { + describe("constructor", () => { + it("should reject duplicate public keys across signatures and unused pubkeys", () => { + expect( + () => + new Multisig( + createParams({ + signatures: [{ pub_key: pubkey1, signature: "aa" }], + unusedPubKeys: [pubkey1, pubkey2], + }), + ), ).toThrow(InvalidMultisigParameterError); }); - it("should require all inputs to be transaction V0", () => { - const unsignedTx = createUnsignedTx(); - const txV1: Transaction = { - V1: { - unused_pub_keys: ["pubkey2"], - signatures: [{ pub_key: "pubkey1", signature: "sig1" }], - min_signers: 1, - ...unsignedTx, - }, - }; - - expect(() => - MultisigTransaction.fromTransactions({ - txns: [txV1], - minSigners: 1, - allPubKeys: ["pubkey1"], - }), - ).toThrow(InvalidTransactionVersionError); + it("should reject thresholds larger than the signer set", () => { + expect( + () => + new Multisig( + createParams({ + unusedPubKeys: [pubkey1, pubkey2], + minSigners: 3, + }), + ), + ).toThrow(InvalidMultisigParameterError); }); - it("should require all inputs to match the same unsigned transaction", () => { - const unsignedTx1 = createUnsignedTx(1); - const unsignedTx2 = createUnsignedTx(2); - const tx1 = createTransactionV0(unsignedTx1, "pubkey1", "sig1"); - const tx2 = createTransactionV0(unsignedTx2, "pubkey2", "sig2"); - - expect(() => - MultisigTransaction.fromTransactions({ - txns: [tx1, tx2], - minSigners: 2, - allPubKeys: ["pubkey1", "pubkey2"], - }), - ).toThrow(TransactionMismatchError); + it("should reject an empty signer set", () => { + expect( + () => + new Multisig( + createParams({ + unusedPubKeys: [], + minSigners: 1, + }), + ), + ).toThrow(InvalidMultisigParameterError); }); - it("should return multisig with only unique signatures (no duplicate transactions)", () => { - const unsignedTx = createUnsignedTx(); - const tx1 = createTransactionV0(unsignedTx, "pubkey1", "sig1"); - const tx2 = createTransactionV0(unsignedTx, "pubkey2", "sig1"); // same signature - - const multisig = MultisigTransaction.fromTransactions({ - txns: [tx1, tx2], - minSigners: 2, - allPubKeys: ["pubkey1", "pubkey2"], - }); - - const result = multisig.asTransaction() as TransactionV1; - expect(result.V1.signatures).toHaveLength(2); - expect(result.V1.signatures).toEqual([ - { pub_key: "pubkey1", signature: "sig1" }, - { pub_key: "pubkey2", signature: "sig1" }, - ]); + it("should reject a single-signer multisig", () => { + expect( + () => + new Multisig( + createParams({ + unusedPubKeys: [pubkey1], + minSigners: 1, + }), + ), + ).toThrow(InvalidMultisigParameterError); }); - it("should throw if an input transaction uses a pubkey not in the unused list", () => { - const unsignedTx = createUnsignedTx(); - const tx = createTransactionV0(unsignedTx, "unknown_pubkey", "sig1"); - - expect(() => - MultisigTransaction.fromTransactions({ - txns: [tx], - minSigners: 1, - allPubKeys: ["pubkey1", "pubkey2"], - }), + it("should reject invalid public key hex as a multisig validation error", () => { + expect( + () => + new Multisig( + createParams({ + unusedPubKeys: ["xyz", pubkey2], + }), + ), ).toThrow(InvalidMultisigParameterError); }); + }); - it("should throw if same pubkey is used twice", () => { - const unsignedTx = createUnsignedTx(); - const tx1 = createTransactionV0(unsignedTx, "pubkey1", "sig1"); - const tx2 = createTransactionV0(unsignedTx, "pubkey1", "sig2"); + describe("fromPubKeys", () => { + it("should create a multisig with the full signer set unused", () => { + const multisig = Multisig.fromPubKeys([pubkey1, pubkey2], 2); - expect(() => - MultisigTransaction.fromTransactions({ - txns: [tx1, tx2], - minSigners: 2, - allPubKeys: ["pubkey1", "pubkey2"], - }), - ).toThrow(InvalidMultisigParameterError); + expect(multisig.threshold).toBe(2); + expect(multisig.signaturesAndPubKeys).toEqual([]); + expect([...multisig.remainingPubKeys]).toEqual([pubkey1, pubkey2]); }); }); - describe("isComplete", () => { - it("should return true if number of unique signatures is equal to or greater than minSigners", () => { - const multisig = new MultisigTransaction({ - unsignedTx: createUnsignedTx(), - signatures: [ - { pub_key: "pubkey1", signature: "sig1" }, - { pub_key: "pubkey2", signature: "sig2" }, - { pub_key: "pubkey3", signature: "sig3" }, - ], - unusedPubKeys: [], - minSigners: 2, - }); - - expect(multisig.isComplete).toBe(true); + describe("addSignature", () => { + it("should add a signature and remove the signer from remaining keys", () => { + const multisig = new Multisig(createParams()); - const multisigExact = new MultisigTransaction({ - unsignedTx: createUnsignedTx(), - signatures: [ - { pub_key: "pubkey1", signature: "sig1" }, - { pub_key: "pubkey2", signature: "sig2" }, - ], - unusedPubKeys: [], - minSigners: 2, - }); + multisig.addSignature("aa", pubkey1); - expect(multisigExact.isComplete).toBe(true); + expect(multisig.signaturesAndPubKeys).toEqual([ + { pub_key: pubkey1, signature: "aa" }, + ]); + expect([...multisig.remainingPubKeys]).toEqual([pubkey2, pubkey3]); }); - it("should return false if number of unique signatures is less than minSigners", () => { - const multisig = new MultisigTransaction({ - unsignedTx: createUnsignedTx(), - signatures: [{ pub_key: "pubkey1", signature: "sig1" }], - unusedPubKeys: ["pubkey2", "pubkey3"], - minSigners: 2, - }); + it("should reject unknown or duplicate signers", () => { + const multisig = new Multisig(createParams()); - expect(multisig.isComplete).toBe(false); - }); - }); + expect(() => multisig.addSignature("aa", "11")).toThrow( + InvalidMultisigParameterError, + ); - describe("addSignature", () => { - it("should throw if pubkey is not in unused list", () => { - const multisig = new MultisigTransaction({ - unsignedTx: createUnsignedTx(), - signatures: [], - unusedPubKeys: ["pubkey1", "pubkey2"], - minSigners: 2, - }); + multisig.addSignature("aa", pubkey1); - expect(() => multisig.addSignature("sig1", "unknown_pubkey")).toThrow( + expect(() => multisig.addSignature("bb", pubkey1)).toThrow( InvalidMultisigParameterError, ); }); - it("should add signature to the signatures set", () => { - const multisig = new MultisigTransaction({ - unsignedTx: createUnsignedTx(), + it("should normalize user-supplied hex strings", () => { + const multisig = new Multisig({ signatures: [], - unusedPubKeys: ["pubkey1", "pubkey2"], - minSigners: 2, + unusedPubKeys: [ + `0x${pubkey1.toUpperCase()}`, + `0x${pubkey2.toUpperCase()}`, + ], + minSigners: 1, }); - multisig.addSignature("sig1", "pubkey1"); + multisig.addSignature("0xAA", `0x${pubkey1.toUpperCase()}`); - const result = multisig.asTransaction() as TransactionV1; - expect(result.V1.signatures).toEqual([ - { pub_key: "pubkey1", signature: "sig1" }, + expect(multisig.signaturesAndPubKeys).toEqual([ + { pub_key: pubkey1, signature: "aa" }, ]); - expect(result.V1.signatures).toHaveLength(1); + expect([...multisig.remainingPubKeys]).toEqual([pubkey2]); }); + }); - it("should remove pubkey from unused list", () => { - const multisig = new MultisigTransaction({ - unsignedTx: createUnsignedTx(), - signatures: [], - unusedPubKeys: ["pubkey1", "pubkey2"], - minSigners: 2, - }); + describe("completion", () => { + it("should track whether enough signatures have been collected", () => { + const multisig = new Multisig(createParams()); - multisig.addSignature("sig1", "pubkey1"); + expect(multisig.isComplete).toBe(false); + + multisig.addSignature("aa", pubkey1); + expect(multisig.isComplete).toBe(false); - const result = multisig.asTransaction() as TransactionV1; - expect(result.V1.unused_pub_keys).not.toContain("pubkey1"); - expect(result.V1.unused_pub_keys).toContain("pubkey2"); - expect(result.V1.unused_pub_keys).toHaveLength(1); + multisig.addSignature("bb", pubkey2); + expect(multisig.isComplete).toBe(true); }); + }); - it("should throw if trying to add signature for already used pubkey", () => { - const multisig = new MultisigTransaction({ - unsignedTx: createUnsignedTx(), - signatures: [], - unusedPubKeys: ["pubkey1", "pubkey2"], - minSigners: 2, - }); + describe("toTransaction", () => { + const unsignedTx = { + runtime_call: { test: "call" }, + uniqueness: { nonce: 1 }, + details: { + max_priority_fee_bips: 0, + max_fee: "1000", + gas_limit: null, + chain_id: 1, + }, + }; + + it("should reject incomplete multisig transactions", () => { + const multisig = new Multisig(createParams()); + + expect(() => multisig.toTransaction(unsignedTx)).toThrow( + "Multisig transaction is incomplete", + ); + }); - multisig.addSignature("sig1", "pubkey1"); + it("should convert a complete multisig into a V1 transaction", () => { + const multisig = new Multisig(createParams()); - expect(() => multisig.addSignature("sig2", "pubkey1")).toThrow( - InvalidMultisigParameterError, - ); + multisig.addSignature("aa", pubkey1); + multisig.addSignature("bb", pubkey2); + + expect(multisig.toTransaction(unsignedTx)).toEqual({ + V1: { + ...unsignedTx, + signatures: [ + { pub_key: pubkey1, signature: "aa" }, + { pub_key: pubkey2, signature: "bb" }, + ], + unused_pub_keys: [pubkey3], + min_signers: 2, + }, + }); }); }); describe("getMultisigAddress", () => { - it("should match Rust implementation output for known test vector", () => { - const multisig = MultisigTransaction.empty(createUnsignedTx(), 2, [ - "33dd646d7c43830b52289c4f277d3a5a26b3ab0a10ee05c871cb654bc046e545", - "8c7788ad88084f12ce1556703b33e673c5e450eec793c1e8e1a55b1140b4dfb8", - "74fc1e9c39b21173ef47c1cdfb37158764486c8b8ba81d8f29c6dd77800cd57f", - ]); + it("should expose the shared signer cap", () => { + expect(MAX_SIGNERS).toBe(21); + }); + + it("should match the Rust test vector", () => { + const multisig = Multisig.fromPubKeys([pubkey1, pubkey2, pubkey3], 2); + + expect(bytesToHex(multisig.getMultisigAddress())).toBe( + "814394e81dd2a682efad0fc2082272cde35a172ba7e0e2240b0a0e9d68af23ee", + ); + }); - const address = multisig.getMultisigAddress(); - const addressHex = bytesToHex(address); + it("should be invariant to current signature collection state", () => { + const multisig = new Multisig( + createParams({ + signatures: [{ pub_key: pubkey3, signature: "cc" }], + unusedPubKeys: [pubkey1, pubkey2], + }), + ); - expect(addressHex).toBe( + expect(bytesToHex(multisig.getMultisigAddress())).toBe( "814394e81dd2a682efad0fc2082272cde35a172ba7e0e2240b0a0e9d68af23ee", ); }); diff --git a/typescript/packages/multisig/src/index.ts b/typescript/packages/multisig/src/index.ts index b333e94ab8..fa85d767c6 100644 --- a/typescript/packages/multisig/src/index.ts +++ b/typescript/packages/multisig/src/index.ts @@ -1,35 +1,20 @@ import { sha256 } from "@noble/hashes/sha2"; import type { SignatureAndPubKey, - Transaction, - TransactionV0, - UnsignedTransaction, + TransactionV1, + UnsignedTransactionV0, } from "@sovereign-sdk/types"; import type { HexString } from "@sovereign-sdk/utils"; -import { bytesToHex, hexToBytes } from "@sovereign-sdk/utils"; +import { hexToBytes, normalizeHexString } from "@sovereign-sdk/utils"; import * as borsh from "borsh"; +export const MAX_SIGNERS = 21; + /** * Base error class for multisig-related errors. */ export class MultisigError extends Error {} -/** - * Error thrown when a transaction has an unsupported version for multisig operations. - */ -export class InvalidTransactionVersionError extends MultisigError {} - -/** - * Error thrown when a provided transaction doesn't match the expected unsigned transaction. - */ -export class TransactionMismatchError extends Error { - constructor() { - super( - "Provided transaction does not match the expected unsigned transaction", - ); - } -} - /** * Error thrown when multisig is constructed or used with invalid parameters. */ @@ -40,13 +25,11 @@ export class InvalidMultisigParameterError extends MultisigError { } /** - * Parameters for constructing a MultisigTransaction. + * Parameters for constructing a Multisig. */ export type MultisigParams = { - /** The unsigned transaction to be signed by multiple parties */ - unsignedTx: UnsignedTransaction; /** Array of signatures with pubkeys already collected */ - signatures: SignatureAndPubKey[]; + signatures?: SignatureAndPubKey[]; /** Array of public keys that haven't signed yet */ unusedPubKeys: HexString[]; /** Minimum number of signatures required */ @@ -54,196 +37,99 @@ export type MultisigParams = { }; /** - * Parameters for creating a MultisigTransaction from existing signed transactions. + * Represents multisig signer membership and collected signatures. */ -export type FromTransactionsParams = { - /** Array of signed transactions to extract signatures from */ - txns: Transaction[]; - /** Minimum number of signatures required */ - minSigners: number; - /** All public keys that are members of the multisig */ - allPubKeys: HexString[]; -}; - -/** - * Represents a multisig transaction that collects signatures from multiple parties. - * Only supports nonce-based transactions and V0 transaction variants for signature collection. - */ -export class MultisigTransaction { - private unsignedTx: UnsignedTransaction; - private signatures: SignatureAndPubKey[] = []; +export class Multisig { + private signatures: SignatureAndPubKey[]; private minSigners: number; - private unusedPubKeys = new Set(); + private unusedPubKeys: Set; + + constructor({ signatures = [], unusedPubKeys, minSigners }: MultisigParams) { + const normalizedSignatures = signatures.map(normalizeSignatureAndPubKey); + const normalizedUnusedPubKeys = unusedPubKeys.map((pubKey) => + normalizeMultisigHex("public key", pubKey), + ); - /** - * Creates a new MultisigTransaction instance. - * @param params - The multisig parameters including unsigned transaction, signatures, and public keys - */ - constructor({ - unsignedTx, - signatures, - unusedPubKeys, - minSigners, - }: MultisigParams) { - this.unsignedTx = unsignedTx; - this.signatures = signatures; - this.unusedPubKeys = new Set(unusedPubKeys); + assertValidSignerSet( + normalizedSignatures, + normalizedUnusedPubKeys, + minSigners, + ); + + this.signatures = normalizedSignatures; + this.unusedPubKeys = new Set(normalizedUnusedPubKeys); this.minSigners = minSigners; } - /** - * Creates an empty MultisigTransaction with no signatures collected yet. - * @param unsignedTx - The unsigned transaction to be signed by multiple parties - * @param minSigners - Minimum number of signatures required for completion - * @param allPubKeys - All public keys that are members of the multisig - * @returns A new empty MultisigTransaction instance - */ - static empty( - unsignedTx: UnsignedTransaction, - minSigners: number, - allPubKeys: HexString[], - ): MultisigTransaction { - return new MultisigTransaction({ - unsignedTx, + static fromPubKeys(allPubKeys: HexString[], minSigners: number): Multisig { + return new Multisig({ signatures: [], unusedPubKeys: allPubKeys, minSigners, }); } - /** - * Creates a MultisigTransaction from an array of already-signed transactions. - * Extracts signatures and validates that all transactions are identical except for signatures. - * @param params - Parameters including the signed transactions, minimum signers, and all public keys - * @returns A new MultisigTransaction instance - * @throws {InvalidTransactionVersionError} If any transaction is not V0 variant - * @throws {TransactionMismatchError} If transactions don't match each other - * @throws {InvalidMultisigParameterError} If public keys are invalid or transaction is not nonce-based - */ - static fromTransactions({ - txns, - minSigners, - allPubKeys, - }: FromTransactionsParams): MultisigTransaction { - const signatures: SignatureAndPubKey[] = []; - const unsignedTx = asUnsignedTransaction(txns[0]); - const unusedPubKeys = new Set(allPubKeys); - - assertIsNonceBasedTx(unsignedTx); - - for (const tx of txns) { - assertTxVariant(tx); - assertTxMatchesUnsignedTx(tx, unsignedTx); - - const { pub_key, signature } = tx.V0; - - if (!unusedPubKeys.delete(pub_key)) { - throw new InvalidMultisigParameterError( - `Public key is not a member of the multisig or has already signed: ${pub_key}`, - ); - } - - signatures.push({ pub_key, signature }); - } - - return new MultisigTransaction({ - unsignedTx, - signatures, - unusedPubKeys: Array.from(unusedPubKeys), - minSigners, - }); - } - - /** - * Adds a signature & public key from a signed transaction to the multisig transaction. - * @param tx - The signed transaction to extract signature from - * @throws {InvalidTransactionVersionError} If the transaction is not V0 variant - * @throws {TransactionMismatchError} If the transaction doesn't match the unsigned transaction - * @throws {InvalidMultisigParameterError} If the public key is not a member or has already signed - */ - addSignedTransaction(tx: Transaction): void { - assertTxVariant(tx); - assertTxMatchesUnsignedTx(tx, this.unsignedTx); - - const { pub_key, signature } = tx.V0; - - this.addSignature(signature, pub_key); - } - - /** - * Adds a signature to the multisig transaction. - * @param signature - The signature to add - * @param pubKey - The public key corresponding to the signature - * @throws {InvalidMultisigParameterError} If the public key is not a member or has already signed - */ addSignature(signature: HexString, pubKey: HexString): void { - if (!this.unusedPubKeys.delete(pubKey)) { + const pair = normalizeSignatureAndPubKey({ signature, pub_key: pubKey }); + + if (!this.unusedPubKeys.delete(pair.pub_key)) { throw new InvalidMultisigParameterError( - `Public key is not a member of the multisig or has already signed: ${pubKey}`, + `Public key is not a member of the multisig or has already signed: ${pair.pub_key}`, ); } - this.signatures.push({ pub_key: pubKey, signature }); + this.signatures.push(pair); } - /** - * Checks if the multisig has collected enough signatures to be complete. - * @returns True if the number of signatures meets or exceeds the minimum required - */ get isComplete(): boolean { return this.signatures.length >= this.minSigners; } - /** - * Gets the unsigned transaction. - * @returns A readonly view of the unsigned transaction - */ - get unsignedTransaction(): Readonly> { - return this.unsignedTx; + get threshold(): number { + return this.minSigners; } - /** - * Gets the signatures and public keys collected so far. - * @returns A readonly view of the signatures and public keys - */ get signaturesAndPubKeys(): Readonly { - return this.signatures; + return this.signatures.map((pair) => ({ ...pair })); } - /** - * Gets the remaining public keys that haven't signed yet. - * @returns A readonly view of the remaining public keys - */ get remainingPubKeys(): Readonly> { - return this.unusedPubKeys; + return new Set(this.unusedPubKeys); } - /** - * Gets the threshold number of signatures required for the multisig to be complete. - * @returns The threshold number of signatures - */ - get threshold(): number { - return this.minSigners; + get allPubKeys(): Readonly { + return [ + ...this.signatures.map((signature) => signature.pub_key), + ...this.unusedPubKeys, + ]; + } + + toTransaction( + unsignedTx: UnsignedTransactionV0, + ): TransactionV1 { + if (!this.isComplete) { + throw new MultisigError("Multisig transaction is incomplete"); + } + + return { + V1: { + ...unsignedTx, + signatures: [...this.signaturesAndPubKeys], + unused_pub_keys: [...this.remainingPubKeys], + min_signers: this.threshold, + }, + }; } - /** - * Calculates the multisig address (credential ID) by hashing the threshold and sorted public keys. - * This matches the Rust implementation: hash(threshold || borsh(sorted_pubkeys)) - * @param hasher - The hash algorithm to use (currently only 'sha256' is supported) - * @returns The 32-byte multisig address as a Uint8Array - */ getMultisigAddress(hasher: "sha256" = "sha256"): Uint8Array { if (hasher !== "sha256") { throw new Error(`Unsupported hasher: ${hasher}`); } - // Collect all public keys (signatures + unused) - const allPubKeys = [ - ...this.signatures.map((s) => s.pub_key), - ...Array.from(this.unusedPubKeys), - ]; - const sortedPubKeys = allPubKeys.sort(); - const pubKeyBytes = sortedPubKeys.map((pk) => Array.from(hexToBytes(pk))); + const sortedPubKeys = [...this.allPubKeys].sort(); + const pubKeyBytes = sortedPubKeys.map((pubKey) => + Array.from(hexToBytes(pubKey)), + ); const buffer = new borsh.BinaryWriter(); buffer.writeU8(this.minSigners); @@ -253,74 +139,66 @@ export class MultisigTransaction { return sha256(buffer.toArray()); } - - /** - * Converts the multisig to a V1 transaction that can be submitted to the network. - * @returns A V1 transaction containing all collected signatures and unused public keys - */ - asTransaction(): Transaction { - return { - V1: { - runtime_call: this.unsignedTx.runtime_call, - uniqueness: this.unsignedTx.uniqueness, - details: this.unsignedTx.details, - unused_pub_keys: Array.from(this.unusedPubKeys), - signatures: this.signatures, - min_signers: this.minSigners, - }, - }; - } } -function assertTxVariant( - tx: Transaction, -): asserts tx is TransactionV0 { - if ("V1" in tx) { - throw new InvalidTransactionVersionError( - "Input transaction was an unsupported variant (V1)", +function assertValidSignerSet( + signatures: SignatureAndPubKey[], + unusedPubKeys: HexString[], + minSigners: number, +): void { + // `signatures` and `unusedPubKeys` are expected to already be normalized and + // validated hex strings by the time this runs. + const seenPubKeys = new Set(); + const allPubKeys = [ + ...signatures.map((signature) => signature.pub_key), + ...unusedPubKeys, + ]; + + if (!Number.isInteger(minSigners) || minSigners < 1) { + throw new InvalidMultisigParameterError( + `minSigners must be a positive integer, got ${minSigners}`, ); } -} -function asUnsignedTransaction( - tx: Transaction, -): UnsignedTransaction { - if ("V0" in tx) { - return { - runtime_call: tx.V0.runtime_call, - uniqueness: tx.V0.uniqueness, - details: tx.V0.details, - }; + if (allPubKeys.length < 2 || allPubKeys.length > MAX_SIGNERS) { + throw new InvalidMultisigParameterError( + `expected 2-${MAX_SIGNERS} total signers, got ${allPubKeys.length}`, + ); } - if ("V1" in tx) { - return { - runtime_call: tx.V1.runtime_call, - uniqueness: tx.V1.uniqueness, - details: tx.V1.details, - }; + if (minSigners > allPubKeys.length) { + throw new InvalidMultisigParameterError( + `minSigners cannot exceed total signer count: ${minSigners} > ${allPubKeys.length}`, + ); } - throw new InvalidTransactionVersionError( - "Transaction variant is neither V0 nor V1", - ); -} - -function assertTxMatchesUnsignedTx( - tx: Transaction, - unsignedTx: UnsignedTransaction, -): void { - const txAsUnsigned = asUnsignedTransaction(tx); + for (const pubKey of allPubKeys) { + if (seenPubKeys.has(pubKey)) { + throw new InvalidMultisigParameterError( + `Duplicate multisig public key: ${pubKey}`, + ); + } - if (JSON.stringify(txAsUnsigned) !== JSON.stringify(unsignedTx)) { - throw new TransactionMismatchError(); + seenPubKeys.add(pubKey); } } -function assertIsNonceBasedTx(unsignedTx: UnsignedTransaction): void { - if (!("nonce" in unsignedTx.uniqueness)) { +function normalizeSignatureAndPubKey( + pair: SignatureAndPubKey, +): SignatureAndPubKey { + return { + signature: normalizeMultisigHex("signature", pair.signature), + pub_key: normalizeMultisigHex("public key", pair.pub_key), + }; +} + +function normalizeMultisigHex(kind: string, value: HexString): HexString { + try { + return normalizeHexString(value); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); throw new InvalidMultisigParameterError( - "Only nonce-based transactions are supported for multisig", + `Invalid multisig ${kind} ${value}: ${message}`, ); } } diff --git a/typescript/packages/types/README.md b/typescript/packages/types/README.md index ef11220926..6acf42f57a 100644 --- a/typescript/packages/types/README.md +++ b/typescript/packages/types/README.md @@ -6,8 +6,7 @@ Core type definitions for Sovereign SDK blockchain interactions. This package provides TypeScript type definitions for working with "standard" Sovereign SDK rollups. While Sovereign SDK rollups are fully generic and can define custom types for transactions, blocks, and other primitives, this package contains the default type definitions used by the standard Sovereign SDK implementation. -## Standard Types - +## Standard types While Sovereign SDK supports this level of customization, most rollups will use a common set of primitives. This package provides type definitions for these standard components (and more): - `UnsignedTransaction` - Standard unsigned transaction format @@ -18,15 +17,35 @@ These types work out-of-the-box with the default Sovereign SDK rollup configurat ## Usage ```typescript -import type { UnsignedTransaction, Transaction } from "@sovereign-sdk/types"; +import type { + Transaction, + UnsignedTransaction, + UnsignedTransactionV0, +} from "@sovereign-sdk/types"; + +const unsignedTxV0: UnsignedTransactionV0 = { + runtime_call: { + // Your rollup-specific call data + }, + uniqueness: { nonce: 1 }, + details: { + max_priority_fee_bips: 0, + max_fee: "1000000", + gas_limit: null, + chain_id: 4321, + }, +}; -// Use the standard transaction types -const unsignedTx: UnsignedTransaction = { - // Standard transaction fields +const signingEnvelope: UnsignedTransaction = { + V0: unsignedTxV0, }; -const signedTx: Transaction = { - // Standard signed transaction fields +const signedTx: Transaction = { + V0: { + pub_key: "deadbeef", + signature: "cafebabe", + ...unsignedTxV0, + }, }; ``` @@ -39,4 +58,3 @@ If your rollup uses custom transaction or block formats that differ from the sta 3. Use the generic interfaces provided by other packages in this monorepo The Sovereign SDK's flexibility means you're never locked into these standard definitions if your use case requires something different. - diff --git a/typescript/packages/types/src/index.ts b/typescript/packages/types/src/index.ts index 8265d1c96e..8733ed0931 100644 --- a/typescript/packages/types/src/index.ts +++ b/typescript/packages/types/src/index.ts @@ -25,10 +25,9 @@ export type Nonce = { nonce: number }; export type Uniqueness = Nonce | Generation; /** - * Base transaction structure before signing, containing the core transaction data. - * Generic over RuntimeCall to support different rollup runtime call types. + * Common unsigned transaction fields shared by all unsigned transaction versions. */ -export type UnsignedTransaction = { +type UnsignedTransactionFields = { /** The specific runtime call/method being invoked on the rollup */ runtime_call: RuntimeCall; /** Uniqueness mechanism (nonce or generation) to prevent replay attacks */ @@ -37,6 +36,33 @@ export type UnsignedTransaction = { details: TxDetails; }; +/** + * Consumer-facing version 0 unsigned transaction. + * Used for standard rollup transaction building and single-signature signing. + */ +export type UnsignedTransactionV0 = + UnsignedTransactionFields; + +/** + * Version 1 unsigned transaction. + * Used internally for multisig signing bytes and includes the credential commitment. + */ +export type UnsignedTransactionV1< + RuntimeCall, + CredentialAddress = unknown, +> = UnsignedTransactionFields & { + /** The multisig credential address in the rollup's native address format */ + credential_address: CredentialAddress; +}; + +/** + * Versioned unsigned transaction envelope. + * This is the exact root object serialized for signing. + */ +export type UnsignedTransaction = + | { V0: UnsignedTransactionV0 } + | { V1: UnsignedTransactionV1 }; + /** * Version 0 transaction format with single signature. * Used for standard single-party transactions. @@ -47,7 +73,7 @@ export type TransactionV0 = { pub_key: HexString; /** Cryptographic signature of the transaction in hex format */ signature: HexString; - } & UnsignedTransaction; + } & UnsignedTransactionV0; }; /** @@ -73,7 +99,7 @@ export type TransactionV1 = { signatures: SignatureAndPubKey[]; /** Minimum number of signatures required for transaction validity */ min_signers: number; - } & UnsignedTransaction; + } & UnsignedTransactionV0; }; /** diff --git a/typescript/packages/utils/src/hex.test.ts b/typescript/packages/utils/src/hex.test.ts index 97baa4c1a9..9a8e52e21c 100644 --- a/typescript/packages/utils/src/hex.test.ts +++ b/typescript/packages/utils/src/hex.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { bytesToHex, ensureBytes, hexToBytes } from "./hex"; +import { bytesToHex, ensureBytes, hexToBytes, normalizeHexString } from "./hex"; describe("hexToBytes", () => { it("should convert a valid hex string to Uint8Array", () => { @@ -40,6 +40,22 @@ describe("bytesToHex", () => { }); }); +describe("normalizeHexString", () => { + it("should strip a 0x prefix and lowercase the value", () => { + expect(normalizeHexString("0xAABBCC")).toBe("aabbcc"); + }); + + it("should preserve already-normalized lowercase hex", () => { + expect(normalizeHexString("0a1b2c")).toBe("0a1b2c"); + }); + + it("should throw for invalid hex input", () => { + expect(() => normalizeHexString("0xZZ")).toThrow( + "Invalid hex string: contains non-hex characters", + ); + }); +}); + describe("ensureBytes", () => { it("should return Uint8Array if input is Uint8Array", () => { const arr = new Uint8Array([1, 2, 3]); diff --git a/typescript/packages/utils/src/hex.ts b/typescript/packages/utils/src/hex.ts index b3787d3858..1f0144f94d 100644 --- a/typescript/packages/utils/src/hex.ts +++ b/typescript/packages/utils/src/hex.ts @@ -45,6 +45,19 @@ export function bytesToHex(bytes: Uint8Array): HexString { return hex; } +/** + * Normalizes a hexadecimal string to the canonical form returned by {@link bytesToHex}. + * + * This strips an optional `0x` prefix, validates the input, and lowercases all digits. + * + * @param hex - The hexadecimal string to normalize. + * @returns The normalized lowercase hexadecimal string without a `0x` prefix. + * @throws {Error} If the input is not valid hexadecimal. + */ +export function normalizeHexString(hex: HexString): HexString { + return bytesToHex(hexToBytes(hex)); +} + /** * Ensures the input is a Uint8Array. If a hex string is provided, it is converted to bytes. * diff --git a/typescript/packages/utils/src/index.ts b/typescript/packages/utils/src/index.ts index 4ef64abcd3..529257d096 100644 --- a/typescript/packages/utils/src/index.ts +++ b/typescript/packages/utils/src/index.ts @@ -1,2 +1,2 @@ -export { hexToBytes, bytesToHex, ensureBytes } from "./hex"; +export { hexToBytes, bytesToHex, normalizeHexString, ensureBytes } from "./hex"; export { type HexString } from "./hex"; diff --git a/typescript/packages/web3/README.md b/typescript/packages/web3/README.md index 476335b6d9..7b28b576ea 100644 --- a/typescript/packages/web3/README.md +++ b/typescript/packages/web3/README.md @@ -116,6 +116,38 @@ const simulation = await rollup.simulate( ); ``` +### Multisig + +```typescript +import { Multisig } from "@sovereign-sdk/multisig"; +import type { UnsignedTransactionV0 } from "@sovereign-sdk/types"; +import { bytesToHex } from "@sovereign-sdk/utils"; + +const unsignedTx: UnsignedTransactionV0 = + await rollup.buildUnsignedTransaction(runtimeCall, { + overrides: { uniqueness: { nonce: 1 } }, + }); + +const multisig = Multisig.fromPubKeys( + ["pubkey1hex", "pubkey2hex", "pubkey3hex"], + 2, +); + +const signingBytes = await rollup.multisigSigningBytes(unsignedTx, multisig); +multisig.addSignature( + bytesToHex(await signer1.sign(signingBytes)), + bytesToHex(await signer1.publicKey()), +); + +multisig.addSignature( + bytesToHex(await signer2.sign(signingBytes)), + bytesToHex(await signer2.publicKey()), +); + +const tx = multisig.toTransaction(unsignedTx); +await rollup.submitTransaction(tx); +``` + ## API Reference The package exports the following main components: @@ -125,4 +157,3 @@ The package exports the following main components: - `createSerializer`: Function to create a Borsh serializer for your rollup schema For detailed API documentation, please refer to the inline TypeScript documentation in the source code. - diff --git a/typescript/packages/web3/package.json b/typescript/packages/web3/package.json index 0f10afb52c..02f10eed88 100644 --- a/typescript/packages/web3/package.json +++ b/typescript/packages/web3/package.json @@ -33,6 +33,7 @@ "dependencies": { "bs58": "^6.0.0", "@sovereign-sdk/client": "0.1.0-alpha.39", + "@sovereign-sdk/multisig": "workspace:^", "@sovereign-sdk/serializers": "workspace:^", "@sovereign-sdk/signers": "workspace:^", "@sovereign-sdk/universal-wallet-wasm": "workspace:^", diff --git a/typescript/packages/web3/src/rollup/rollup.ts b/typescript/packages/web3/src/rollup/rollup.ts index 72b4dc1dca..3f3b24e42c 100644 --- a/typescript/packages/web3/src/rollup/rollup.ts +++ b/typescript/packages/web3/src/rollup/rollup.ts @@ -2,6 +2,7 @@ import SovereignClient from "@sovereign-sdk/client"; import type { APIError } from "@sovereign-sdk/client"; import type { RollupSchema, Serializer } from "@sovereign-sdk/serializers"; import type { Signer } from "@sovereign-sdk/signers"; +import type { HexString } from "@sovereign-sdk/utils"; import { bytesToHex, hexToBytes } from "@sovereign-sdk/utils"; import { Base64 } from "js-base64"; import { VersionMismatchError } from "../errors"; @@ -46,6 +47,11 @@ export type TypeBuilder = { */ export type RollupContext = Record; +export type CredentialIdToAddress = ( + credentialId: Uint8Array, + schema: RollupSchema, +) => string; + /** * The configuration for a rollup client. */ @@ -66,6 +72,10 @@ export type RollupConfig = { * is detected then it will be called again with the new rollup schema. */ getSerializer: (schema: RollupSchema) => Serializer; + /** + * Optional hook for converting multisig credential IDs into the rollup's native address format. + */ + credentialIdToAddress?: CredentialIdToAddress; /** * Arbitrary context that is associated with the rollup. */ @@ -143,12 +153,34 @@ export class Rollup { * TODO: How to add param for generation explicitly */ async dedup(publicKey: Uint8Array): Promise { - // for public key credential id is just its bytes representation - const credentialId = bytesToHex(publicKey); + return this.dedupByCredentialId(bytesToHex(publicKey)); + } + + /** + * Retrieve dedup information about the provided credential ID. + */ + async dedupByCredentialId(credentialId: HexString): Promise { const response = await this.rollup.addresses.dedup(credentialId); return response as S["Dedup"]; } + /** + * Builds an unsigned transaction for the provided runtime call. + */ + async buildUnsignedTransaction( + runtimeCall: S["RuntimeCall"], + params?: { overrides?: DeepPartial }, + ): Promise { + const context = { + runtimeCall, + rollup: this, + overrides: + params?.overrides ?? ({} as DeepPartial), + }; + + return this._typeBuilder.unsignedTransaction(context); + } + /** * Submits a transaction to the rollup. * @@ -228,12 +260,9 @@ export class Rollup { { signer, overrides }: CallParams, options?: SovereignClient.RequestOptions, ): Promise> { - const context = { - runtimeCall, - rollup: this, - overrides: overrides ?? ({} as DeepPartial), - }; - const unsignedTx = await this._typeBuilder.unsignedTransaction(context); + const unsignedTx = await this.buildUnsignedTransaction(runtimeCall, { + overrides, + }); return this.signAndSubmitTransaction( unsignedTx, @@ -259,15 +288,18 @@ export class Rollup { runtimeCall: S["RuntimeCall"], { signer, overrides }: CallParams, ): Promise { - const context = { - runtimeCall, - rollup: this, - overrides: overrides ?? ({} as DeepPartial), - }; - const unsignedTx = await this._typeBuilder.unsignedTransaction(context); + const unsignedTx = await this.buildUnsignedTransaction(runtimeCall, { + overrides, + }); return this.signTransaction(unsignedTx, signer); } + protected async unsignedTxForSigning( + unsignedTx: S["UnsignedTransaction"], + ): Promise { + return unsignedTx; + } + /** * Signs an unsigned transaction using the provided signer. * Creates a signature by combining the serialized unsigned transaction with the chain hash, @@ -282,7 +314,9 @@ export class Rollup { signer: Signer, ): Promise { const serializer = await this.serializer(); - const serializedUnsignedTx = serializer.serializeUnsignedTx(unsignedTx); + const signingUnsignedTx = await this.unsignedTxForSigning(unsignedTx); + const serializedUnsignedTx = + serializer.serializeUnsignedTx(signingUnsignedTx); const chainHash = await this.chainHash(); const signature = await signer.sign( new Uint8Array([...serializedUnsignedTx, ...chainHash]), diff --git a/typescript/packages/web3/src/rollup/solana-signable-rollup.test.ts b/typescript/packages/web3/src/rollup/solana-signable-rollup.test.ts index 49295a0848..e84123d66b 100644 --- a/typescript/packages/web3/src/rollup/solana-signable-rollup.test.ts +++ b/typescript/packages/web3/src/rollup/solana-signable-rollup.test.ts @@ -1,9 +1,8 @@ -import { sha256 } from "@noble/hashes/sha2"; import SovereignClient from "@sovereign-sdk/client"; +import { Multisig } from "@sovereign-sdk/multisig"; import { JsSerializer } from "@sovereign-sdk/serializers"; import { Ed25519Signer } from "@sovereign-sdk/signers"; -import { LedgerSolanaSigner } from "@sovereign-sdk/signers/ledger-solana"; -import { bytesToHex, hexToBytes } from "@sovereign-sdk/utils"; +import { bytesToHex } from "@sovereign-sdk/utils"; import { describe, expect, it, vi } from "vitest"; import demoRollupSchema from "../../../__fixtures__/demo-rollup-schema.json"; import { @@ -125,6 +124,10 @@ describe("SolanaSignableRollup", () => { it("should allow custom Solana endpoint configuration", async () => { const mockClient = createMockClient(); const customEndpoint = "/custom/solana-tx-endpoint"; + const requestOptions = { + timeout: 1234, + headers: { "x-test-header": "solana" }, + }; // Capture the endpoint and payload sent to the client let capturedEndpoint: string | undefined; @@ -164,10 +167,13 @@ describe("SolanaSignableRollup", () => { signer: createMockSigner(), authenticator: "solanaSimple", }, + requestOptions, ); expect(capturedEndpoint).toBe(customEndpoint); expect(capturedPayload).toHaveProperty("body"); + expect(capturedPayload.timeout).toBe(requestOptions.timeout); + expect(capturedPayload.headers).toEqual(requestOptions.headers); }); it("should read chain_name from schema.chain_data in fixture schema", async () => { @@ -425,8 +431,6 @@ describe("SolanaSignableRollup", () => { const signer2 = new Ed25519Signer(key2PrivHex); const signer3 = new Ed25519Signer(key3PrivHex); - // Compute the multisig address from the 3 public keys. - // Mirrors MultisigTransaction.getMultisigAddress() from @sovereign-sdk/multisig. const pub1 = await signer1.publicKey(); const pub2 = await signer2.publicKey(); const pub3 = await signer3.publicKey(); @@ -434,16 +438,10 @@ describe("SolanaSignableRollup", () => { const pub2Hex = bytesToHex(pub2); const pub3Hex = bytesToHex(pub3); const minSigners = 2; - const multisigPubkeys = [pub1, pub2, pub3]; - - const sortedPubKeys = [pub1Hex, pub2Hex, pub3Hex].sort(); - const pubKeyBytes = sortedPubKeys.map((pk) => Array.from(hexToBytes(pk))); - const borshData = new Uint8Array(1 + 4 + 3 * 32); - const dv = new DataView(borshData.buffer); - borshData[0] = minSigners; - dv.setUint32(1, 3, true); - pubKeyBytes.forEach((pk, i) => borshData.set(pk, 5 + i * 32)); - const multisigAddress = sha256(borshData); + const multisig = Multisig.fromPubKeys( + [pub1Hex, pub2Hex, pub3Hex], + minSigners, + ); const runtimeCall = { bank: { @@ -469,41 +467,27 @@ describe("SolanaSignableRollup", () => { }, }; - // Each signer signs independently (same order as Rust: key3, key1) - const signedTx3 = await rollup.signTransactionForMultisig(unsignedTx, { - signer: signer3, - authenticator: "solanaSimple", - multisigAddress, - multisigPubkeys, - }); - const signedTx1 = await rollup.signTransactionForMultisig(unsignedTx, { - signer: signer1, - authenticator: "solanaSimple", - multisigAddress, - multisigPubkeys, - }); - - // Build V1 transaction manually (avoids a cyclic dependency on @sovereign-sdk/multisig) - const v0_3 = (signedTx3 as any).V0; - const v0_1 = (signedTx1 as any).V0; - - const multisigV1 = { - V1: { - ...unsignedTx, - signatures: [ - { pub_key: v0_3.pub_key, signature: v0_3.signature }, - { pub_key: v0_1.pub_key, signature: v0_1.signature }, - ], - unused_pub_keys: [pub2Hex], - min_signers: minSigners, - }, - }; + const signer3Bytes = await rollup.multisigSigningBytes( + unsignedTx, + multisig, + "solanaSimple", + ); + multisig.addSignature( + bytesToHex(await signer3.sign(signer3Bytes)), + bytesToHex(await signer3.publicKey()), + ); + const signer1Bytes = await rollup.multisigSigningBytes( + unsignedTx, + multisig, + "solanaSimple", + ); + multisig.addSignature( + bytesToHex(await signer1.sign(signer1Bytes)), + bytesToHex(await signer1.publicKey()), + ); + const multisigV1 = multisig.toTransaction(unsignedTx); - await rollup.submitMultisigTransaction(multisigV1 as any, { - authenticator: "solanaSimple", - multisigAddress, - multisigPubkeys, - }); + await rollup.submitTransaction(multisigV1 as any, "solanaSimple"); const actualJson = JSON.stringify(capturedPayload); expect(actualJson).toBe(expectedJson); @@ -540,29 +524,26 @@ describe("SolanaSignableRollup", () => { }, }; - const signedTx = await rollup.signTransactionForMultisig( + const pubKeyHex = bytesToHex(await signer.publicKey()); + const otherPubKeyHex = bytesToHex(new Uint8Array(32).fill(9)); + const multisig = Multisig.fromPubKeys([pubKeyHex, otherPubKeyHex], 1); + + const signingBytes = await rollup.multisigSigningBytes( unsignedTx as any, - { - signer, - authenticator: "standard", - }, + multisig, + "standard", + ); + multisig.addSignature( + bytesToHex(await signer.sign(signingBytes)), + bytesToHex(await signer.publicKey()), ); - expect(signedTx).toHaveProperty("V0"); + expect(multisig.signaturesAndPubKeys).toHaveLength(1); expect(signer.sign).toHaveBeenCalledTimes(1); - await rollup.submitMultisigTransaction( - { - V1: { - ...unsignedTx, - signatures: [], - unused_pub_keys: [], - min_signers: 0, - }, - } as any, - { - authenticator: "standard", - }, + await rollup.submitTransaction( + multisig.toTransaction(unsignedTx as any), + "standard", ); expect(capturedEndpoint).toBe("/sequencer/txs"); @@ -611,16 +592,10 @@ describe("SolanaSignableRollup", () => { const pub2Hex = bytesToHex(pub2); const pub3Hex = bytesToHex(pub3); const minSigners = 2; - const multisigPubkeys = [pub1, pub2, pub3]; - - const sortedPubKeys = [pub1Hex, pub2Hex, pub3Hex].sort(); - const pubKeyBytes = sortedPubKeys.map((pk) => Array.from(hexToBytes(pk))); - const borshData = new Uint8Array(1 + 4 + 3 * 32); - const dv = new DataView(borshData.buffer); - borshData[0] = minSigners; - dv.setUint32(1, 3, true); - pubKeyBytes.forEach((pk, i) => borshData.set(pk, 5 + i * 32)); - const multisigAddress = sha256(borshData); + const multisig = Multisig.fromPubKeys( + [pub1Hex, pub2Hex, pub3Hex], + minSigners, + ); const unsignedTx = { runtime_call: { @@ -644,39 +619,27 @@ describe("SolanaSignableRollup", () => { }, }; - const signedTx3 = await rollup.signTransactionForMultisig(unsignedTx, { - signer: signer3, - authenticator: "solana", - multisigAddress, - multisigPubkeys, - }); - const signedTx1 = await rollup.signTransactionForMultisig(unsignedTx, { - signer: signer1, - authenticator: "solana", - multisigAddress, - multisigPubkeys, - }); - - const v0_3 = (signedTx3 as any).V0; - const v0_1 = (signedTx1 as any).V0; - - const multisigV1 = { - V1: { - ...unsignedTx, - signatures: [ - { pub_key: v0_3.pub_key, signature: v0_3.signature }, - { pub_key: v0_1.pub_key, signature: v0_1.signature }, - ], - unused_pub_keys: [pub2Hex], - min_signers: minSigners, - }, - }; + const signer3Bytes = await rollup.multisigSigningBytes( + unsignedTx, + multisig, + "solana", + ); + multisig.addSignature( + bytesToHex(await signer3.sign(signer3Bytes)), + bytesToHex(await signer3.publicKey()), + ); + const signer1Bytes = await rollup.multisigSigningBytes( + unsignedTx, + multisig, + "solana", + ); + multisig.addSignature( + bytesToHex(await signer1.sign(signer1Bytes)), + bytesToHex(await signer1.publicKey()), + ); + const multisigV1 = multisig.toTransaction(unsignedTx); - await rollup.submitMultisigTransaction(multisigV1 as any, { - authenticator: "solana", - multisigAddress, - multisigPubkeys, - }); + await rollup.submitTransaction(multisigV1 as any, "solana"); const actualJson = JSON.stringify(capturedPayload.body); expect(actualJson).toBe(expectedJson); @@ -703,10 +666,11 @@ describe("SolanaSignableRollup", () => { }), }); - // Create a real LedgerSolanaSigner instance and mock its methods - const ledgerSigner = new LedgerSolanaSigner(); - vi.spyOn(ledgerSigner, "publicKey").mockResolvedValue(new Uint8Array(32)); - vi.spyOn(ledgerSigner, "sign").mockResolvedValue(new Uint8Array(64)); + const ledgerSigner = { + __ledgerSolanaSigner: true as const, + publicKey: vi.fn().mockResolvedValue(new Uint8Array(32)), + sign: vi.fn().mockResolvedValue(new Uint8Array(64)), + }; await rollup.signAndSubmitTransaction( { diff --git a/typescript/packages/web3/src/rollup/solana-signable-rollup.ts b/typescript/packages/web3/src/rollup/solana-signable-rollup.ts index 27a9740eba..cbf5c49780 100644 --- a/typescript/packages/web3/src/rollup/solana-signable-rollup.ts +++ b/typescript/packages/web3/src/rollup/solana-signable-rollup.ts @@ -1,11 +1,17 @@ import type SovereignClient from "@sovereign-sdk/client"; +import { MAX_SIGNERS, Multisig } from "@sovereign-sdk/multisig"; import { type Signer, isLedgerSolanaSigner } from "@sovereign-sdk/signers"; import type { Transaction, TransactionV1, - UnsignedTransaction, + UnsignedTransactionV0, } from "@sovereign-sdk/types"; -import { bytesToHex, hexToBytes } from "@sovereign-sdk/utils"; +import type { HexString } from "@sovereign-sdk/utils"; +import { + bytesToHex, + hexToBytes, + normalizeHexString, +} from "@sovereign-sdk/utils"; import bs58 from "bs58"; import { Base64 } from "js-base64"; import type { Subscription, SubscriptionToCallbackMap } from "../subscriptions"; @@ -20,15 +26,17 @@ import { } from "./standard-rollup"; export type SolanaOffchainUnsignedTransaction = - UnsignedTransaction & { + UnsignedTransactionV0 & { chain_name: string; }; -export type SolanaOffchainUnsignedTransactionV1 = - SolanaOffchainUnsignedTransaction & { - multisig_id: string; - version: number; - }; +export type SolanaOffchainUnsignedTransactionV1< + RuntimeCall, + MultisigId = unknown, +> = SolanaOffchainUnsignedTransaction & { + multisig_id: MultisigId; + version: number; +}; export type SolanaOffchainSimpleMessage = { signed_message: Uint8Array; @@ -64,22 +72,7 @@ export type Authenticator = | "solana" | "solanaAuto"; -export type SolanaMultisigAuthenticator = "solanaSimple" | "solana"; - -type SolanaMultisigParams = { - multisigAddress: Uint8Array; - multisigPubkeys: Uint8Array[]; -}; - -export type SolanaMultisigSubmitParams = - | { - authenticator: "standard"; - } - | ({ authenticator: SolanaMultisigAuthenticator } & SolanaMultisigParams); - -export type SolanaMultisigSignParams = SolanaMultisigSubmitParams & { - signer: Signer; -}; +export type SubmissionAuthenticator = Exclude; /** * Discriminator byte prepended to the wire bytes in the multisig simple format. @@ -94,7 +87,6 @@ const CHAIN_HASH_SIZE = 32; const PUBKEY_SIZE = 32; const SIGNATURE_SIZE = 64; const MULTISIG_PREAMBLE_FIXED_LENGTH = 53; -const MAX_MULTISIG_SIGNERS = 21; // TODO - is there any way we could get it from Rust rather than hard-coding here // Solana preamble constants const SIGNING_DOMAIN = new Uint8Array([ @@ -121,9 +113,9 @@ function createSolanaPreamble( chainHash: Uint8Array, messageLength: number, ): Uint8Array { - if (pubkeys.length < 1 || pubkeys.length > MAX_MULTISIG_SIGNERS) { + if (pubkeys.length < 1 || pubkeys.length > MAX_SIGNERS) { throw new Error( - `Invalid signer count: expected 1-${MAX_MULTISIG_SIGNERS} signers, got ${pubkeys.length}`, + `Invalid signer count: expected 1-${MAX_SIGNERS} signers, got ${pubkeys.length}`, ); } @@ -190,12 +182,16 @@ export class SolanaSignableRollup { */ private async submitSerializedMessage( serializedMessage: Uint8Array, + options?: SovereignClient.RequestOptions, ): Promise { + const { body: _, query: __, path, ...requestOptions } = options || {}; + return await this.inner.http.post( - this.solanaEndpoint, + path || this.solanaEndpoint, { // Match AcceptTx shape used by standard sequencer endpoints. body: { body: Base64.fromUint8Array(serializedMessage) }, + ...requestOptions, }, ); } @@ -205,9 +201,10 @@ export class SolanaSignableRollup { */ private async submitSolanaMessage( solanaMessage: SolanaOffchainSimpleMessage, + options?: SovereignClient.RequestOptions, ): Promise { const serializedMessage = this.serializeSolanaMessage(solanaMessage); - return this.submitSerializedMessage(serializedMessage); + return this.submitSerializedMessage(serializedMessage, options); } /** @@ -215,9 +212,10 @@ export class SolanaSignableRollup { */ private async submitSolanaSpecMessage( solanaMessage: SolanaOffchainSpecCompliantMessage, + options?: SovereignClient.RequestOptions, ): Promise { const serializedMessage = this.serializeSolanaSpecMessage(solanaMessage); - return this.submitSerializedMessage(serializedMessage); + return this.submitSerializedMessage(serializedMessage, options); } /** @@ -225,24 +223,11 @@ export class SolanaSignableRollup { */ private async submitSolanaSpecMultisigMessage( solanaMessage: SolanaOffchainSpecCompliantMultisigMessage, + options?: SovereignClient.RequestOptions, ): Promise { const serializedMessage = this.serializeSolanaSpecMultisigMessage(solanaMessage); - return this.submitSerializedMessage(serializedMessage); - } - - /** - * Helper to build an unsigned transaction using the standard type builder. - */ - private async buildUnsignedTransaction( - runtimeCall: RuntimeCall, - overrides?: DeepPartial>, - ): Promise> { - return this.typeBuilder.unsignedTransaction({ - runtimeCall, - overrides: overrides ?? {}, - rollup: this.inner, - }); + return this.submitSerializedMessage(serializedMessage, options); } /** @@ -250,7 +235,7 @@ export class SolanaSignableRollup { */ private async buildTransactionResult( response: SovereignClient.Sequencer.TxCreateResponse, - unsignedTx: UnsignedTransaction, + unsignedTx: UnsignedTransactionV0, pubkey: Uint8Array, signature: Uint8Array, ): Promise>> { @@ -281,25 +266,19 @@ export class SolanaSignableRollup { * The canonical order is lexicographic by raw pubkey bytes. */ private canonicalizeMultisigPubkeys( - multisigPubkeys?: Uint8Array[], + multisigPubkeys: HexString[], ): Uint8Array[] { - if (!multisigPubkeys) { - throw new Error( - "multisigPubkeys is required for Solana multisig transactions", - ); - } - - if ( - multisigPubkeys.length < 2 || - multisigPubkeys.length > MAX_MULTISIG_SIGNERS - ) { + if (multisigPubkeys.length < 1 || multisigPubkeys.length > MAX_SIGNERS) { throw new Error( - `Invalid multisig signer count: expected 2-${MAX_MULTISIG_SIGNERS} signers, got ${multisigPubkeys.length}`, + `Invalid multisig signer count: expected 1-${MAX_SIGNERS} signers, got ${multisigPubkeys.length}`, ); } const seenPubkeys = new Set(); - for (const pubkey of multisigPubkeys) { + const normalizedPubkeys = multisigPubkeys.map(normalizeHexString); + const pubkeyBytes = normalizedPubkeys.map((pubkey) => hexToBytes(pubkey)); + + for (const pubkey of pubkeyBytes) { if (pubkey.length !== PUBKEY_SIZE) { throw new Error( `Invalid public key length: expected ${PUBKEY_SIZE} bytes, got ${pubkey.length}`, @@ -313,14 +292,14 @@ export class SolanaSignableRollup { seenPubkeys.add(pubkeyHex); } - return [...multisigPubkeys].sort(compareByteArrays); + return pubkeyBytes.sort(compareByteArrays); } /** * Helper to create and serialize a SolanaOffchainUnsignedTransaction to JSON bytes. */ private async createSolanaJsonBytes( - unsignedTx: UnsignedTransaction, + unsignedTx: UnsignedTransactionV0, ): Promise { const serializer = await this.inner.serializer(); const schema = serializer.schema; @@ -342,8 +321,9 @@ export class SolanaSignableRollup { * Returns the transaction result in the same format as standard rollup. */ private async signWithSolanaSimpleAndSubmit( - unsignedTx: UnsignedTransaction, + unsignedTx: UnsignedTransactionV0, signer: Signer, + options?: SovereignClient.RequestOptions, ): Promise>> { const jsonBytes = await this.createSolanaJsonBytes(unsignedTx); @@ -360,7 +340,7 @@ export class SolanaSignableRollup { signature: signature, }; - const response = await this.submitSolanaMessage(solanaMessage); + const response = await this.submitSolanaMessage(solanaMessage, options); return this.buildTransactionResult(response, unsignedTx, pubkey, signature); } @@ -369,8 +349,9 @@ export class SolanaSignableRollup { * Returns the transaction result in the same format as standard rollup. */ private async signWithSolanaSpecAndSubmit( - unsignedTx: UnsignedTransaction, + unsignedTx: UnsignedTransactionV0, signer: Signer, + options?: SovereignClient.RequestOptions, ): Promise>> { const jsonBytes = await this.createSolanaJsonBytes(unsignedTx); @@ -395,7 +376,7 @@ export class SolanaSignableRollup { signature: signature, }; - const response = await this.submitSolanaSpecMessage(solanaMessage); + const response = await this.submitSolanaSpecMessage(solanaMessage, options); return this.buildTransactionResult(response, unsignedTx, pubkey, signature); } @@ -412,7 +393,7 @@ export class SolanaSignableRollup { params: { signer: Signer; authenticator: Authenticator; - overrides?: DeepPartial>; + overrides?: DeepPartial>; }, options?: SovereignClient.RequestOptions, ): Promise>> { @@ -433,18 +414,30 @@ export class SolanaSignableRollup { options, ); case "solanaSimple": { - const unsignedTx = await this.buildUnsignedTransaction( + const unsignedTx = await this.inner.buildUnsignedTransaction( runtimeCall, - params.overrides, + { + overrides: params.overrides, + }, + ); + return this.signWithSolanaSimpleAndSubmit( + unsignedTx, + params.signer, + options, ); - return this.signWithSolanaSimpleAndSubmit(unsignedTx, params.signer); } case "solana": { - const unsignedTx = await this.buildUnsignedTransaction( + const unsignedTx = await this.inner.buildUnsignedTransaction( runtimeCall, - params.overrides, + { + overrides: params.overrides, + }, + ); + return this.signWithSolanaSpecAndSubmit( + unsignedTx, + params.signer, + options, ); - return this.signWithSolanaSpecAndSubmit(unsignedTx, params.signer); } default: throw new Error(`Unsupported authenticator: ${authenticator}`); @@ -460,7 +453,7 @@ export class SolanaSignableRollup { * @returns The transaction result */ async signAndSubmitTransaction( - unsignedTx: UnsignedTransaction, + unsignedTx: UnsignedTransactionV0, params: { signer: Signer; authenticator: Authenticator }, options?: SovereignClient.RequestOptions, ): Promise>> { @@ -479,21 +472,36 @@ export class SolanaSignableRollup { options, ); case "solanaSimple": - return this.signWithSolanaSimpleAndSubmit(unsignedTx, params.signer); + return this.signWithSolanaSimpleAndSubmit( + unsignedTx, + params.signer, + options, + ); case "solana": - return this.signWithSolanaSpecAndSubmit(unsignedTx, params.signer); + return this.signWithSolanaSpecAndSubmit( + unsignedTx, + params.signer, + options, + ); default: throw new Error(`Unsupported authenticator: ${authenticator}`); } } + async buildUnsignedTransaction( + runtimeCall: RuntimeCall, + params?: { overrides?: DeepPartial> }, + ): Promise> { + return this.inner.buildUnsignedTransaction(runtimeCall, params); + } + /** * Creates V1 JSON bytes for a multisig transaction, including multisig_id and version. * The resulting JSON is what each signer signs directly (no discriminator prefix). */ private async createMultisigJsonBytes( - unsignedTx: UnsignedTransaction, - multisigAddress: Uint8Array, + unsignedTx: UnsignedTransactionV0, + multisigId: unknown, ): Promise { const serializer = await this.inner.serializer(); const schema = serializer.schema; @@ -504,31 +512,83 @@ export class SolanaSignableRollup { uniqueness: unsignedTx.uniqueness, details: unsignedTx.details, chain_name: chainName, - // Hardcoded to base58 encoding, only correct for rollups using Base58Address as their - // primary address type. Will be replaced with rollup-aware address formatting once the - // SDK supports flexible address encoding (see #2673). - multisig_id: bs58.encode(multisigAddress), + multisig_id: multisigId, version: 1, }; return new TextEncoder().encode(JSON.stringify(solanaUnsignedTx)); } + /** + * Formats the multisig credential in the rollup's configured address format. + */ + private async multisigIdFromMultisig(multisig: Multisig): Promise { + const credentialId = multisig.getMultisigAddress(); + + if (this.inner.context.credentialIdToAddress) { + const serializer = await this.inner.serializer(); + return this.inner.context.credentialIdToAddress( + credentialId, + serializer.schema, + ); + } + + return bs58.encode(credentialId); + } + + /** + * Creates multisig state from a finalized transaction envelope. + */ + private multisigFromTransaction( + tx: TransactionV1["V1"], + ): Multisig { + return new Multisig({ + signatures: tx.signatures, + unusedPubKeys: tx.unused_pub_keys, + minSigners: tx.min_signers, + }); + } + + private normalizeMultisigTransaction( + tx: TransactionV1["V1"], + ): TransactionV1["V1"] { + return { + ...tx, + signatures: tx.signatures.map((signature) => ({ + signature: normalizeHexString(signature.signature), + pub_key: normalizeHexString(signature.pub_key), + })), + unused_pub_keys: tx.unused_pub_keys.map(normalizeHexString), + }; + } + + /** + * Extracts the V0-shaped unsigned fields from a finalized multisig transaction. + */ + private unsignedTxFromTransaction( + tx: TransactionV1["V1"], + ): UnsignedTransactionV0 { + return { + runtime_call: tx.runtime_call, + uniqueness: tx.uniqueness, + details: tx.details, + }; + } + /** * Creates the preamble+JSON bytes signed by every signer in a spec-compliant multisig flow. */ private async createSpecCompliantMultisigSignedMessage( - unsignedTx: UnsignedTransaction, - multisigAddress: Uint8Array, - multisigPubkeys: Uint8Array[], + unsignedTx: UnsignedTransactionV0, + multisig: Multisig, ): Promise { const jsonBytes = await this.createMultisigJsonBytes( unsignedTx, - multisigAddress, + await this.multisigIdFromMultisig(multisig), ); const chainHash = await this.inner.chainHash(); const preamble = createSolanaPreamble( - multisigPubkeys, + this.canonicalizeMultisigPubkeys([...multisig.allPubKeys]), chainHash, jsonBytes.length, ); @@ -536,116 +596,40 @@ export class SolanaSignableRollup { return this.combinePreambleAndMessage(preamble, jsonBytes); } - /** - * Signs an unsigned transaction using Solana offchain simple multisig signing. - */ - private async signForSolanaSimpleMultisig( - unsignedTx: UnsignedTransaction, - signer: Signer, - multisigAddress: Uint8Array, - multisigPubkeys: Uint8Array[], - ): Promise> { - const pubkey = await signer.publicKey(); - const signerPubkeyHex = bytesToHex(pubkey); - const multisigPubkeyHexes = multisigPubkeys.map(bytesToHex); - - if (!multisigPubkeyHexes.includes(signerPubkeyHex)) { - throw new Error( - `Signer public key ${signerPubkeyHex} is not present in multisigPubkeys`, - ); - } - - const jsonBytes = await this.createMultisigJsonBytes( - unsignedTx, - multisigAddress, - ); - - const signature = await signer.sign(jsonBytes); - - return this.typeBuilder.transaction({ - unsignedTx, - sender: pubkey, - signature, - rollup: this.inner, - }); - } - - /** - * Signs an unsigned transaction using the spec-compliant multisig preamble. - */ - private async signForSolanaSpecMultisig( - unsignedTx: UnsignedTransaction, - signer: Signer, - multisigAddress: Uint8Array, - multisigPubkeys: Uint8Array[], - ): Promise> { - const pubkey = await signer.publicKey(); - const signerPubkeyHex = bytesToHex(pubkey); - const multisigPubkeyHexes = multisigPubkeys.map(bytesToHex); - - if (!multisigPubkeyHexes.includes(signerPubkeyHex)) { - throw new Error( - `Signer public key ${signerPubkeyHex} is not present in multisigPubkeys`, - ); - } - - const signedMessageWithPreamble = - await this.createSpecCompliantMultisigSignedMessage( - unsignedTx, - multisigAddress, - multisigPubkeys, - ); - const signature = await signer.sign(signedMessageWithPreamble); - - return this.typeBuilder.transaction({ - unsignedTx, - sender: pubkey, - signature, - rollup: this.inner, - }); - } - - /** - * Signs an unsigned transaction for use in a Solana offchain multisig. - * - * `authenticator: "standard"` delegates directly to the wrapped standard rollup. - * `authenticator: "solanaSimple"` signs the V1 JSON payload directly. - * `authenticator: "solana"` signs the spec-compliant preamble+JSON bytes. - * Solana multisig authenticators treat `multisigPubkeys` as an unordered set and - * canonicalize it before signing. - */ - async signTransactionForMultisig( - unsignedTx: UnsignedTransaction, - params: SolanaMultisigSignParams, - ): Promise> { - switch (params.authenticator) { + async multisigSigningBytes( + unsignedTx: UnsignedTransactionV0, + multisig: Multisig, + authenticator: SubmissionAuthenticator, + ): Promise { + switch (authenticator) { case "standard": - return this.inner.signTransaction(unsignedTx, params.signer); + return this.inner.multisigSigningBytes(unsignedTx, multisig); case "solanaSimple": - return this.signForSolanaSimpleMultisig( + return this.createMultisigJsonBytes( unsignedTx, - params.signer, - params.multisigAddress, - this.canonicalizeMultisigPubkeys(params.multisigPubkeys), + await this.multisigIdFromMultisig(multisig), ); case "solana": - return this.signForSolanaSpecMultisig( + return this.createSpecCompliantMultisigSignedMessage( unsignedTx, - params.signer, - params.multisigAddress, - this.canonicalizeMultisigPubkeys(params.multisigPubkeys), + multisig, ); } + + throw new Error(`Unsupported authenticator: ${authenticator}`); } /** - * Builds a spec-compliant multisig envelope from a V1 transaction and ordered multisig pubkeys. + * Builds a spec-compliant multisig envelope from a V1 transaction. */ private buildSpecCompliantMultisigEnvelope( tx: TransactionV1["V1"], signedMessageWithPreamble: Uint8Array, - multisigPubkeys: Uint8Array[], ): SolanaOffchainSpecCompliantMultisigMessage { + const multisigPubkeys = this.canonicalizeMultisigPubkeys([ + ...tx.signatures.map((signer) => signer.pub_key), + ...tx.unused_pub_keys, + ]); const multisigPubkeyHexes = multisigPubkeys.map(bytesToHex); const indexByPubkey = new Map(); @@ -658,41 +642,46 @@ export class SolanaSignableRollup { let signerBitfield = 0; for (const signer of tx.signatures) { - const signerIndex = indexByPubkey.get(signer.pub_key); + const normalizedSignerPubkey = normalizeHexString(signer.pub_key); + const signerIndex = indexByPubkey.get(normalizedSignerPubkey); if (signerIndex === undefined) { throw new Error( - `Signed pubkey ${signer.pub_key} is not present in multisigPubkeys`, + `Signed pubkey ${normalizedSignerPubkey} is not present in multisigPubkeys`, ); } - if (accountedPubkeys.has(signer.pub_key)) { + if (accountedPubkeys.has(normalizedSignerPubkey)) { throw new Error( - `Duplicate signed pubkey in multisig transaction: ${signer.pub_key}`, + `Duplicate signed pubkey in multisig transaction: ${normalizedSignerPubkey}`, ); } - accountedPubkeys.add(signer.pub_key); - signaturesByIndex.set(signerIndex, hexToBytes(signer.signature)); + accountedPubkeys.add(normalizedSignerPubkey); + signaturesByIndex.set( + signerIndex, + hexToBytes(normalizeHexString(signer.signature)), + ); signerBitfield = (signerBitfield | (1 << signerIndex)) >>> 0; } for (const unusedPubkey of tx.unused_pub_keys) { - if (!indexByPubkey.has(unusedPubkey)) { + const normalizedUnusedPubkey = normalizeHexString(unusedPubkey); + if (!indexByPubkey.has(normalizedUnusedPubkey)) { throw new Error( - `Unused pubkey ${unusedPubkey} is not present in multisigPubkeys`, + `Unused pubkey ${normalizedUnusedPubkey} is not present in multisigPubkeys`, ); } - if (accountedPubkeys.has(unusedPubkey)) { + if (accountedPubkeys.has(normalizedUnusedPubkey)) { throw new Error( - `Duplicate pubkey in multisig transaction payload: ${unusedPubkey}`, + `Duplicate pubkey in multisig transaction payload: ${normalizedUnusedPubkey}`, ); } - accountedPubkeys.add(unusedPubkey); + accountedPubkeys.add(normalizedUnusedPubkey); } if (accountedPubkeys.size !== multisigPubkeys.length) { throw new Error( - "multisigPubkeys must contain every signer and unused pubkey exactly once", + "Multisig transaction payload must contain every signer exactly once", ); } @@ -709,36 +698,29 @@ export class SolanaSignableRollup { } /** - * Submits a multisig transaction using the Solana offchain simple multisig format. - * - * Accepts the V1 transaction produced by `MultisigTransaction.asTransaction()`. - * `authenticator: "standard"` delegates directly to the wrapped standard rollup. - * Solana authenticators rebuild the appropriate Solana multisig envelope and - * submit it to the Solana offchain endpoint. + * Submits a finalized V1 multisig transaction using a Solana authenticator. */ - async submitMultisigTransaction( - multisigTx: TransactionV1, - params: SolanaMultisigSubmitParams, + private async submitMultisigTransactionWithAuthenticator( + transaction: TransactionV1, + authenticator: Exclude, + options?: SovereignClient.RequestOptions, ): Promise { - const { V1: tx } = multisigTx; + const finalizedTx = { + V1: this.normalizeMultisigTransaction(transaction.V1), + } as TransactionV1; + const multisig = this.multisigFromTransaction(finalizedTx.V1); - const unsignedTx: UnsignedTransaction = { - runtime_call: tx.runtime_call, - uniqueness: tx.uniqueness, - details: tx.details, - }; + if (!multisig.isComplete) { + throw new Error("Multisig transaction is incomplete"); + } - switch (params.authenticator) { - case "standard": - return this.inner.submitTransaction( - multisigTx as StandardRollupSpec["Transaction"], - ); - case "solanaSimple": { - this.canonicalizeMultisigPubkeys(params.multisigPubkeys); + const unsignedTx = this.unsignedTxFromTransaction(finalizedTx.V1); + switch (authenticator) { + case "solanaSimple": { const jsonBytes = await this.createMultisigJsonBytes( unsignedTx, - params.multisigAddress, + await this.multisigIdFromMultisig(multisig), ); const wireBytes = new Uint8Array(1 + jsonBytes.length); wireBytes[0] = MULTISIG_SIMPLE_DISCRIMINATOR; @@ -748,78 +730,88 @@ export class SolanaSignableRollup { const serialized = this.serializeSolanaMultisigMessage({ wire_bytes: wireBytes, chain_hash: chainHash, - signatures: tx.signatures.map((s) => ({ + signatures: finalizedTx.V1.signatures.map((s) => ({ signature: hexToBytes(s.signature), pub_key: hexToBytes(s.pub_key), })), - unused_pub_keys: tx.unused_pub_keys.map((pk) => hexToBytes(pk)), - min_signers: tx.min_signers, + unused_pub_keys: finalizedTx.V1.unused_pub_keys.map((pk) => + hexToBytes(pk), + ), + min_signers: finalizedTx.V1.min_signers, }); - return this.submitSerializedMessage(serialized); + return this.submitSerializedMessage(serialized, options); } case "solana": { - const multisigPubkeys = this.canonicalizeMultisigPubkeys( - params.multisigPubkeys, - ); const signedMessageWithPreamble = await this.createSpecCompliantMultisigSignedMessage( unsignedTx, - params.multisigAddress, - multisigPubkeys, + multisig, ); const message = this.buildSpecCompliantMultisigEnvelope( - tx, + finalizedTx.V1, signedMessageWithPreamble, - multisigPubkeys, ); - return this.submitSolanaSpecMultisigMessage(message); + return this.submitSolanaSpecMultisigMessage(message, options); } } } /** - * Submits a standard transaction. - */ - async submitTransaction( - transaction: StandardRollupSpec["Transaction"], - authenticator: "standard", - options?: SovereignClient.RequestOptions, - ): Promise; - - /** - * Submits a Solana offchain message. + * Submits a finalized V0 transaction using a Solana authenticator. */ - async submitTransaction( - transaction: SolanaOffchainSimpleMessage, - authenticator: "solanaSimple", + private async submitSingleSignatureTransactionWithAuthenticator( + transaction: Extract, { V0: unknown }>["V0"], + authenticator: Exclude, options?: SovereignClient.RequestOptions, - ): Promise; + ): Promise { + const unsignedTx: UnsignedTransactionV0 = { + runtime_call: transaction.runtime_call, + uniqueness: transaction.uniqueness, + details: transaction.details, + }; + const pubkey = hexToBytes(normalizeHexString(transaction.pub_key)); + const signature = hexToBytes(normalizeHexString(transaction.signature)); - /** - * Submits a Solana spec-compliant message. - */ - async submitTransaction( - transaction: SolanaOffchainSpecCompliantMessage, - authenticator: "solana", - options?: SovereignClient.RequestOptions, - ): Promise; + switch (authenticator) { + case "solanaSimple": { + const signedMessage = await this.createSolanaJsonBytes(unsignedTx); + const chainHash = await this.inner.chainHash(); + const message: SolanaOffchainSimpleMessage = { + signed_message: signedMessage, + chain_hash: chainHash, + pubkey, + signature, + }; + return this.submitSolanaMessage(message, options); + } + case "solana": { + const signedMessage = await this.createSolanaJsonBytes(unsignedTx); + const chainHash = await this.inner.chainHash(); + const preamble = createSolanaPreamble( + [pubkey], + chainHash, + signedMessage.length, + ); + const message: SolanaOffchainSpecCompliantMessage = { + signed_message_with_preamble: this.combinePreambleAndMessage( + preamble, + signedMessage, + ), + signature, + }; + return this.submitSolanaSpecMessage(message, options); + } + } + } /** * Submits a transaction with the specified authenticator. - * - * @param transaction - Either a standard transaction or a Solana message - * @param authenticator - The authenticator type to use - * @param options - Optional request options - * @returns The transaction response */ async submitTransaction( - transaction: - | StandardRollupSpec["Transaction"] - | SolanaOffchainSimpleMessage - | SolanaOffchainSpecCompliantMessage, - authenticator: Authenticator, + transaction: Transaction, + authenticator: SubmissionAuthenticator, options?: SovereignClient.RequestOptions, ): Promise { switch (authenticator) { @@ -828,19 +820,35 @@ export class SolanaSignableRollup { transaction as StandardRollupSpec["Transaction"], options, ); - case "solanaSimple": { - // For Solana simple, we expect a SolanaOffchainSimpleMessage - const solanaMessage = transaction as SolanaOffchainSimpleMessage; - return await this.submitSolanaMessage(solanaMessage); - } - case "solana": { - // For Solana spec-compliant, we expect a SolanaOffchainSpecCompliantMessage - const solanaMessage = transaction as SolanaOffchainSpecCompliantMessage; - return await this.submitSolanaSpecMessage(solanaMessage); - } - default: - throw new Error(`Unsupported authenticator: ${authenticator}`); + case "solanaSimple": + if ("V1" in transaction) { + return this.submitMultisigTransactionWithAuthenticator( + transaction, + "solanaSimple", + options, + ); + } + return this.submitSingleSignatureTransactionWithAuthenticator( + transaction.V0, + "solanaSimple", + options, + ); + case "solana": + if ("V1" in transaction) { + return this.submitMultisigTransactionWithAuthenticator( + transaction, + "solana", + options, + ); + } + return this.submitSingleSignatureTransactionWithAuthenticator( + transaction.V0, + "solana", + options, + ); } + + throw new Error(`Unsupported authenticator: ${authenticator}`); } async simulate( @@ -854,6 +862,10 @@ export class SolanaSignableRollup { return this.inner.dedup(address); } + async dedupByCredentialId(credentialId: HexString) { + return this.inner.dedupByCredentialId(credentialId); + } + async serializer() { return this.inner.serializer(); } diff --git a/typescript/packages/web3/src/rollup/standard-rollup.test.ts b/typescript/packages/web3/src/rollup/standard-rollup.test.ts index 307ae1344e..6376da59c1 100644 --- a/typescript/packages/web3/src/rollup/standard-rollup.test.ts +++ b/typescript/packages/web3/src/rollup/standard-rollup.test.ts @@ -1,6 +1,9 @@ import SovereignClient from "@sovereign-sdk/client"; +import { Multisig } from "@sovereign-sdk/multisig"; import type { RollupSchema, Serializer } from "@sovereign-sdk/serializers"; +import { bytesToHex } from "@sovereign-sdk/utils"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { addressFromPublicKey } from "../addresses"; import { StandardRollup, createStandardRollup, @@ -155,6 +158,26 @@ const mockSerializer = { const getSerializer = (_schema: RollupSchema) => mockSerializer as unknown as Serializer; +function createMockStandardClient() { + const client = new SovereignClient({ fetch: vi.fn() }); + const chainHash = + "0x0000000000000000000000000000000000000000000000000000000000000000"; + + client.rollup = { + constants: vi.fn().mockResolvedValue({ chain_id: 1 }), + schema: vi.fn().mockResolvedValue({ + schema: { chain_data: { chain_id: 1, chain_name: "TestChain" } }, + chain_hash: chainHash, + }), + addresses: { + dedup: vi.fn().mockResolvedValue({ nonce: 5 }), + }, + } as any; + client.post = vi.fn().mockResolvedValue({ status: "submitted" }); + + return client; +} + describe("createStandardRollup", () => { const mockConfig = { client: new SovereignClient({ fetch: vi.fn() }), @@ -254,4 +277,129 @@ describe("createStandardRollup", () => { }, }); }); + + it("should serialize V0 unsigned transactions as versioned envelopes when signing", async () => { + const client = createMockStandardClient(); + const serializer = { + ...mockSerializer, + serializeUnsignedTx: vi.fn().mockReturnValue(new Uint8Array([7, 8, 9])), + }; + const rollup = await createStandardRollup({ + client, + getSerializer: () => serializer as unknown as Serializer, + context: mockConfig.context, + }); + const signer = { + sign: vi.fn().mockResolvedValue(new Uint8Array([1, 2, 3])), + publicKey: vi.fn().mockResolvedValue(new Uint8Array([4, 5, 6])), + }; + const unsignedTx = { + runtime_call: { test: "call" }, + uniqueness: { nonce: 1 }, + details: mockConfig.context.defaultTxDetails, + }; + + await rollup.signTransaction(unsignedTx, signer as any); + + expect(serializer.serializeUnsignedTx).toHaveBeenCalledWith({ + V0: unsignedTx, + }); + }); + + it("should fetch dedup data directly by credential id", async () => { + const client = createMockStandardClient(); + const dedup = vi.fn().mockResolvedValue({ nonce: 9 }); + client.rollup.addresses = { dedup } as any; + const rollup = await createStandardRollup({ + client, + getSerializer, + context: mockConfig.context, + }); + + await expect(rollup.dedupByCredentialId("aabb")).resolves.toEqual({ + nonce: 9, + }); + expect(dedup).toHaveBeenCalledWith("aabb"); + }); + + it("should create multisig signing bytes and finalize via Multisig.toTransaction()", async () => { + const client = createMockStandardClient(); + const serializer = { + ...mockSerializer, + serializeUnsignedTx: vi.fn().mockReturnValue(new Uint8Array([7, 8, 9])), + }; + const rollup = await createStandardRollup({ + client, + getSerializer: () => serializer as unknown as Serializer, + context: mockConfig.context, + }); + const signerPublicKey = new Uint8Array(32).fill(7); + const signer = { + sign: vi.fn().mockResolvedValue(new Uint8Array(64).fill(8)), + publicKey: vi.fn().mockResolvedValue(signerPublicKey), + }; + const otherPublicKey = new Uint8Array(32).fill(9); + const multisig = Multisig.fromPubKeys( + [bytesToHex(signerPublicKey), bytesToHex(otherPublicKey)], + 1, + ); + const unsignedTx = { + runtime_call: { test: "call" }, + uniqueness: { nonce: 1 }, + details: mockConfig.context.defaultTxDetails, + }; + + const signingBytes = await rollup.multisigSigningBytes( + unsignedTx, + multisig, + ); + + expect(serializer.serializeUnsignedTx).toHaveBeenCalledWith({ + V1: { + ...unsignedTx, + credential_address: addressFromPublicKey( + multisig.getMultisigAddress(), + "sov", + ), + }, + }); + expect(signingBytes).toEqual( + new Uint8Array([7, 8, 9, ...new Array(32).fill(0)]), + ); + + const signatureBytes = await signer.sign(signingBytes); + const signature = { + pub_key: bytesToHex(signerPublicKey), + signature: bytesToHex(signatureBytes), + }; + multisig.addSignature(signature.signature, signature.pub_key); + + expect(multisig.toTransaction(unsignedTx)).toEqual({ + V1: { + ...unsignedTx, + signatures: [signature], + unused_pub_keys: [bytesToHex(otherPublicKey)], + min_signers: 1, + }, + }); + }); + + it("should fail fast when converting an incomplete multisig transaction", () => { + const multisig = Multisig.fromPubKeys( + [ + bytesToHex(new Uint8Array(32).fill(1)), + bytesToHex(new Uint8Array(32).fill(2)), + ], + 2, + ); + const unsignedTx = { + runtime_call: { test: "call" }, + uniqueness: { nonce: 1 }, + details: mockConfig.context.defaultTxDetails, + }; + + expect(() => multisig.toTransaction(unsignedTx)).toThrow( + "Multisig transaction is incomplete", + ); + }); }); diff --git a/typescript/packages/web3/src/rollup/standard-rollup.ts b/typescript/packages/web3/src/rollup/standard-rollup.ts index ef16cadca2..b09a606cc8 100644 --- a/typescript/packages/web3/src/rollup/standard-rollup.ts +++ b/typescript/packages/web3/src/rollup/standard-rollup.ts @@ -1,13 +1,17 @@ import SovereignClient from "@sovereign-sdk/client"; +import type { Multisig } from "@sovereign-sdk/multisig"; import { JsSerializer } from "@sovereign-sdk/serializers"; import type { Transaction, TxDetails, UnsignedTransaction, + UnsignedTransactionV0, } from "@sovereign-sdk/types"; import { bytesToHex } from "@sovereign-sdk/utils"; +import { addressFromPublicKey } from "../addresses"; import type { DeepPartial } from "../utils"; import { + type CredentialIdToAddress, Rollup, type RollupConfig, type SignerParams, @@ -22,10 +26,11 @@ export type Dedup = { export type StandardRollupContext = { defaultTxDetails: TxDetails; + credentialIdToAddress?: CredentialIdToAddress; }; export type StandardRollupSpec = { - UnsignedTransaction: UnsignedTransaction; + UnsignedTransaction: UnsignedTransactionV0; Transaction: Transaction; RuntimeCall: RuntimeCall; Dedup: Dedup; @@ -76,7 +81,7 @@ export function standardTypeBuilder< signature: bytesToHex(signature), ...unsignedTx, }, - }; + } as S["Transaction"]; }, }; } @@ -94,6 +99,58 @@ export class StandardRollup extends Rollup< StandardRollupSpec, StandardRollupContext > { + protected async unsignedTxForSigning( + unsignedTx: UnsignedTransactionV0, + ): Promise> { + return { V0: unsignedTx }; + } + + private async credentialAddressFromId( + credentialId: Uint8Array, + ): Promise { + if (this.context.credentialIdToAddress) { + const serializer = await this.serializer(); + return this.context.credentialIdToAddress( + credentialId, + serializer.schema, + ); + } + + return addressFromPublicKey(credentialId, "sov"); + } + + private async multisigUnsignedTxForSigning( + unsignedTx: UnsignedTransactionV0, + multisig: Multisig, + ): Promise> { + return { + V1: { + ...unsignedTx, + credential_address: await this.credentialAddressFromId( + multisig.getMultisigAddress(), + ), + }, + }; + } + + private async signingBytesForUnsignedTx( + unsignedTx: UnsignedTransaction, + ): Promise { + const serializer = await this.serializer(); + const serializedUnsignedTx = serializer.serializeUnsignedTx(unsignedTx); + const chainHash = await this.chainHash(); + return new Uint8Array([...serializedUnsignedTx, ...chainHash]); + } + + async multisigSigningBytes( + unsignedTx: UnsignedTransactionV0, + multisig: Multisig, + ): Promise { + return this.signingBytesForUnsignedTx( + await this.multisigUnsignedTxForSigning(unsignedTx, multisig), + ); + } + /** * Simulates a runtime call transaction. * @@ -122,6 +179,7 @@ export const DEFAULT_TX_DETAILS: Omit = { async function buildContext( client: SovereignClient, context?: DeepPartial, + credentialIdToAddress?: CredentialIdToAddress, ): Promise { const defaultTxDetails = { ...DEFAULT_TX_DETAILS, @@ -135,7 +193,10 @@ async function buildContext( } return { + ...context, defaultTxDetails, + credentialIdToAddress: + credentialIdToAddress ?? context?.credentialIdToAddress, } as C; } @@ -152,7 +213,11 @@ export async function createStandardRollup< const client = config.client ?? new SovereignClient({ baseURL: config.url }); const getSerializer = config.getSerializer ?? ((schema) => new JsSerializer(schema)); - const context = await buildContext(client, config.context); + const context = await buildContext( + client, + config.context, + config.credentialIdToAddress, + ); // Default to the standard transaction submission endpoint const txSubmissionEndpoint = config.txSubmissionEndpoint ?? "/sequencer/txs"; diff --git a/typescript/pnpm-lock.yaml b/typescript/pnpm-lock.yaml index 4179fe31ef..8fd3e21b75 100644 --- a/typescript/pnpm-lock.yaml +++ b/typescript/pnpm-lock.yaml @@ -200,9 +200,15 @@ importers: '@noble/hashes': specifier: ^1.5.0 version: 1.5.0 + '@sovereign-sdk/multisig': + specifier: workspace:^ + version: link:../multisig '@sovereign-sdk/signers': specifier: workspace:^ version: link:../signers + '@sovereign-sdk/utils': + specifier: workspace:^ + version: link:../utils '@sovereign-sdk/web3': specifier: workspace:^ version: link:../web3 @@ -255,9 +261,6 @@ importers: '@noble/hashes': specifier: ^1.5.0 version: 1.8.0 - '@sovereign-sdk/signers': - specifier: workspace:^ - version: link:../signers '@sovereign-sdk/utils': specifier: workspace:^ version: link:../utils @@ -268,9 +271,6 @@ importers: '@sovereign-sdk/types': specifier: workspace:^ version: link:../types - '@sovereign-sdk/web3': - specifier: workspace:^ - version: link:../web3 tsup: specifier: ^8.3.0 version: 8.3.0(@swc/core@1.7.42(@swc/helpers@0.5.17))(postcss@8.5.3)(tsx@4.20.3)(typescript@5.6.3)(yaml@2.7.1) @@ -429,6 +429,9 @@ importers: '@sovereign-sdk/client': specifier: 0.1.0-alpha.39 version: 0.1.0-alpha.39 + '@sovereign-sdk/multisig': + specifier: workspace:^ + version: link:../multisig '@sovereign-sdk/serializers': specifier: workspace:^ version: link:../serializers