From 4310ceb3ffd9a222a9e819fbcf424be88b19cec2 Mon Sep 17 00:00:00 2001 From: TerraTectra Date: Fri, 17 Jul 2026 05:06:20 +0300 Subject: [PATCH] feat(wallet): add SigmaJS prover --- .changeset/bright-sigmas-prove.md | 5 + packages/wallet/README.md | 19 ++ packages/wallet/package.json | 3 +- packages/wallet/src/index.ts | 1 + .../wallet/src/prover/sigmaJsProver.spec.ts | 168 +++++++++++++++++ packages/wallet/src/prover/sigmaJsProver.ts | 175 ++++++++++++++++++ pnpm-lock.yaml | 3 + 7 files changed, 373 insertions(+), 1 deletion(-) create mode 100644 .changeset/bright-sigmas-prove.md create mode 100644 packages/wallet/src/prover/sigmaJsProver.spec.ts create mode 100644 packages/wallet/src/prover/sigmaJsProver.ts diff --git a/.changeset/bright-sigmas-prove.md b/.changeset/bright-sigmas-prove.md new file mode 100644 index 00000000..53a23229 --- /dev/null +++ b/.changeset/bright-sigmas-prove.md @@ -0,0 +1,5 @@ +--- +"@fleet-sdk/wallet": minor +--- + +Add a SigmaJS-backed prover for smart-contract transaction signing and Sigma proposition message proofs. diff --git a/packages/wallet/README.md b/packages/wallet/README.md index de613cdb..b27bffa0 100644 --- a/packages/wallet/README.md +++ b/packages/wallet/README.md @@ -1,3 +1,22 @@ # @fleet-sdk/wallet [![License](https://badgen.net/github/license/fleet-sdk/fleet/)](https://github.com/fleet-sdk/fleet/blob/master/LICENSE) [![npm](https://badgen.net/npm/v/@fleet-sdk/wallet)](https://www.npmjs.com/package/@fleet-sdk/wallet) Key management and signature for Ergo Platform. + +## SigmaJS prover + +`SigmaJsProver` signs transactions through the SigmaJS interpreter, including inputs protected by arbitrary ErgoTree scripts. It requires the blockchain state context and current blockchain parameters used during reduction. + +```ts +import { Network } from "@fleet-sdk/common"; +import { SigmaJsProver } from "@fleet-sdk/wallet"; + +const prover = new SigmaJsProver({ + context, + parameters, + network: Network.Mainnet +}); + +const signedTransaction = prover.signTransaction(unsignedTransaction, keys); +``` + +The prover also implements `signMessage` and `verify` using SigmaJS Sigma proposition proofs. diff --git a/packages/wallet/package.json b/packages/wallet/package.json index c3bb2a0f..76db1643 100644 --- a/packages/wallet/package.json +++ b/packages/wallet/package.json @@ -45,7 +45,8 @@ "@fleet-sdk/serializer": "workspace:^", "@noble/curves": "^2.0.1", "@scure/bip32": "^2.0.1", - "@scure/bip39": "^2.0.1" + "@scure/bip39": "^2.0.1", + "sigmastate-js": "0.4.6" }, "engines": { "node": ">=18" diff --git a/packages/wallet/src/index.ts b/packages/wallet/src/index.ts index c6e2e78c..bf418d5e 100644 --- a/packages/wallet/src/index.ts +++ b/packages/wallet/src/index.ts @@ -1,3 +1,4 @@ export * from "./ergoHDKey"; export * from "./mnemonic"; export * from "./prover/prover"; +export * from "./prover/sigmaJsProver"; diff --git a/packages/wallet/src/prover/sigmaJsProver.spec.ts b/packages/wallet/src/prover/sigmaJsProver.spec.ts new file mode 100644 index 00000000..01dde8fc --- /dev/null +++ b/packages/wallet/src/prover/sigmaJsProver.spec.ts @@ -0,0 +1,168 @@ +import { + ErgoMessage, + Network, + OutputBuilder, + SGroupElement, + SSigmaProp, + TransactionBuilder +} from "@fleet-sdk/core"; +import { hex } from "@fleet-sdk/crypto"; +import { describe, expect, it } from "vitest"; +import { + BLOCKCHAIN_PARAMETERS, + mockBlockchainStateContext, + mockUTxO +} from "../../../mock-chain/src"; +import { ErgoHDKey } from "../ergoHDKey"; +import { generateMnemonic } from "../mnemonic"; +import { Prover } from "./prover"; +import { SigmaJsProver, type SigmaJsProverOptions } from "./sigmaJsProver"; + +const height = 1234209; +const externalAddress = "9gN8gmyaDBuWPZLn8zj9uZxnLUj4TE9rtedtLGNjf6cUhTmoTwc"; + +function createProver(overrides?: Partial): SigmaJsProver { + return new SigmaJsProver({ + context: mockBlockchainStateContext(), + parameters: BLOCKCHAIN_PARAMETERS, + ...overrides + }); +} +function buildTransaction(key: ErgoHDKey) { + const input = mockUTxO({ + value: 1_000_000_000n, + ergoTree: key.address.ergoTree + }); + + return new TransactionBuilder(height) + .from(input) + .to(new OutputBuilder(10_000_000n, externalAddress)) + .sendChangeTo(key.address) + .payMinFee() + .build(); +} + +describe("SigmaJsProver transaction signing", () => { + it("signs an ErgoUnsignedTransaction", () => { + const key = ErgoHDKey.fromMnemonicSync(generateMnemonic()); + const unsignedTx = buildTransaction(key); + const signedTx = createProver().signTransaction(unsignedTx, [key]); + const proof = hex.decode(signedTx.inputs[0].spendingProof?.proofBytes ?? ""); + + expect(signedTx.inputs).toHaveLength(1); + expect(signedTx.outputs).toHaveLength(unsignedTx.outputs.length); + expect(new Prover().verify(unsignedTx, proof, key)).toBe(true); + }); + + it("signs an EIP-12 transaction with explicit options", () => { + const key = ErgoHDKey.fromMnemonicSync(generateMnemonic()); + const unsignedTx = buildTransaction(key).toEIP12Object(); + const prover = createProver({ network: Network.Mainnet, baseCost: 0 }); + const signedTx = prover.signTransaction(unsignedTx, [key]); + const proof = hex.decode(signedTx.inputs[0].spendingProof?.proofBytes ?? ""); + + expect(prover.verify(unsignedTx, proof, key.publicKey)).toBe(true); + }); + it("proves a script that reads a SigmaProp from a register", () => { + const rootKey = ErgoHDKey.fromMnemonicSync(generateMnemonic()); + const signer = rootKey.deriveChild(1); + const input = mockUTxO({ + value: 1_000_000_000n, + ergoTree: "190600e4c6a70408", + additionalRegisters: { + R4: SSigmaProp(SGroupElement(signer.publicKey)).toHex() + } + }); + const unsignedTx = new TransactionBuilder(height) + .from(input) + .sendChangeTo(rootKey.address) + .payMinFee() + .build(); + + const signedTx = createProver().signTransaction(unsignedTx, [signer]); + expect(signedTx.inputs[0].spendingProof?.proofBytes).not.toBe(""); + }); + + it("passes explicitly burned tokens to SigmaJS", () => { + const key = ErgoHDKey.fromMnemonicSync(generateMnemonic()); + const tokenId = "007fd64d1ee54d78dd269c8930a38286caa28d3f29d27cadcb796418ab15c283"; + const input = mockUTxO({ + value: 1_000_000_000n, + ergoTree: key.address.ergoTree, + assets: [{ tokenId, amount: 10n }] + }); + const unsignedTx = new TransactionBuilder(height) + .from(input) + .burnTokens({ tokenId, amount: 1n }) + .sendChangeTo(key.address) + .payMinFee() + .build(); + + const signedTx = createProver().signTransaction(unsignedTx, [key]); + expect(signedTx.inputs[0].spendingProof?.proofBytes).not.toBe(""); + }); + + it("throws when a transaction key has no private data", () => { + const key = ErgoHDKey.fromMnemonicSync(generateMnemonic()); + const unsignedTx = buildTransaction(key); + const neutered = key.wipePrivateData() as ErgoHDKey; + + expect(() => createProver().signTransaction(unsignedTx, [neutered])).toThrow( + "Private key is not present" + ); + }); +}); + +describe("SigmaJsProver message signing", () => { + it("signs messages and interoperates with the existing verifier", () => { + const key = ErgoHDKey.fromMnemonicSync(generateMnemonic()); + const message = ErgoMessage.fromData("hello sigma"); + const prover = createProver(); + const proof = prover.signMessage(message, key); + + expect(prover.verify(message, proof, key)).toBe(true); + expect(new Prover().verify(message, proof, key)).toBe(true); + }); + it("verifies byte, hex and Base58 message forms", () => { + const key = ErgoHDKey.fromMnemonicSync(generateMnemonic()); + const message = ErgoMessage.fromData("message forms"); + const prover = createProver(); + const proof = prover.signMessage(message, key); + const bytes = message.serialize().toBytes(); + + expect(prover.verify(bytes, proof, key.publicKey)).toBe(true); + expect(prover.verify(hex.encode(bytes), hex.encode(proof), key)).toBe(true); + expect(prover.verify(message.encode(), proof, key)).toBe(true); + }); + + it("verifies unsigned and signed transaction message forms", () => { + const key = ErgoHDKey.fromMnemonicSync(generateMnemonic()); + const unsignedTx = buildTransaction(key); + const prover = createProver(); + const signedTx = prover.signTransaction(unsignedTx, [key]); + const proof = hex.decode(signedTx.inputs[0].spendingProof?.proofBytes ?? ""); + + expect(prover.verify(unsignedTx, proof, key)).toBe(true); + expect(prover.verify(unsignedTx.toEIP12Object(), proof, key)).toBe(true); + expect(prover.verify(signedTx, proof, key)).toBe(true); + }); + + it("returns false for invalid proofs and public keys", () => { + const key = ErgoHDKey.fromMnemonicSync(generateMnemonic()); + const message = ErgoMessage.fromData("valid message"); + const prover = createProver(); + const proof = prover.signMessage(message, key); + + expect(prover.verify(ErgoMessage.fromData("different message"), proof, key)).toBe(false); + expect(prover.verify(message, proof, new Uint8Array([1]))).toBe(false); + }); + + it("throws when a message key has no private data", () => { + const key = ErgoHDKey.fromMnemonicSync(generateMnemonic()); + const neutered = key.wipePrivateData() as ErgoHDKey; + + expect(() => createProver().signMessage(ErgoMessage.fromData("test"), neutered)).toThrow( + "Private key is not present" + ); + }); +}); diff --git a/packages/wallet/src/prover/sigmaJsProver.ts b/packages/wallet/src/prover/sigmaJsProver.ts new file mode 100644 index 00000000..87faaa87 --- /dev/null +++ b/packages/wallet/src/prover/sigmaJsProver.ts @@ -0,0 +1,175 @@ +import { + type EIP12UnsignedTransaction, + Network, + type SignedTransaction, + type TokenAmount, + isHex, + utxoDiff, + utxoSum +} from "@fleet-sdk/common"; +import { ErgoMessage, ErgoUnsignedTransaction } from "@fleet-sdk/core"; +import { type ByteInput, bigintBE, ensureBytes, hex } from "@fleet-sdk/crypto"; +import { serializeTransaction } from "@fleet-sdk/serializer"; +import { + ProverBuilder$, + type ProverHints, + ProverHints$, + type ProverSecret, + ProverSecret$, + SigmaProp$, + type SigmaPropProver, + SigmaPropProver$, + type SigmaPropVerifier, + SigmaPropVerifier$ +} from "sigmastate-js/main"; +import type { ErgoHDKey } from "../ergoHDKey"; +import type { ISigmaProver, Message, UnsignedTransaction } from "./prover"; + +type SigmaJsBlockchainParametersInternal = Parameters[0]; +type SigmaJsBuilder = ReturnType; +type SigmaJsTransactionProver = ReturnType; +type SigmaJsBlockchainStateContextInternal = Parameters[0]; + +export type SigmaJsBlockchainParameters = { + storageFeeFactor: number; + minValuePerByte: number; + maxBlockSize: number; + tokenAccessCost: number; + inputCost: number; + dataInputCost: number; + outputCost: number; + maxBlockCost: number; + softForkStartingHeight?: number; + softForkVotesCollected?: number; + blockVersion: number; +}; + +export type SigmaJsBlockchainStateContext = { + sigmaLastHeaders: unknown[]; + previousStateDigest: string; + sigmaPreHeader: unknown; +}; + +const proverHintsRuntime = ProverHints$ as unknown as { empty(): ProverHints }; +const proverSecretRuntime = ProverSecret$ as unknown as { dlog(secret: bigint): ProverSecret }; +const sigmaPropProverRuntime = SigmaPropProver$ as unknown as { + withSecrets(secrets: ProverSecret[]): SigmaPropProver; +}; +const sigmaPropVerifierRuntime = SigmaPropVerifier$ as unknown as { + create(): SigmaPropVerifier; +}; + +export type SigmaJsProverOptions = { + context: SigmaJsBlockchainStateContext; + parameters: SigmaJsBlockchainParameters; + network?: Network; + baseCost?: number; +}; +export class SigmaJsProver implements ISigmaProver { + private readonly context: SigmaJsBlockchainStateContext; + private readonly parameters: SigmaJsBlockchainParameters; + private readonly network: Network; + private readonly baseCost: number; + + constructor(options: SigmaJsProverOptions) { + this.context = options.context; + this.parameters = options.parameters; + this.network = options.network ?? Network.Mainnet; + this.baseCost = options.baseCost ?? 0; + } + + signTransaction(unsignedTx: UnsignedTransaction, keys: ErgoHDKey[]): SignedTransaction { + const tx = flattenTransactionObject(unsignedTx); + const builder = ProverBuilder$.create( + this.parameters as SigmaJsBlockchainParametersInternal, + this.network + ); + + for (const key of keys) { + if (!key.privateKey) throw new Error("Private key is not present"); + builder.withDLogSecret(bigintBE.encode(key.privateKey)); + } + + const prover = builder.build(); + const reducedTx = prover.reduce( + this.context as SigmaJsBlockchainStateContextInternal, + tx, + tx.inputs, + tx.dataInputs, + getBurningTokens(tx), + this.baseCost + ); + return prover.signReduced(reducedTx, undefined) as SignedTransaction; + } + + signMessage(message: ErgoMessage, key: ErgoHDKey): Uint8Array { + if (!key.privateKey) throw new Error("Private key is not present"); + + const secret = proverSecretRuntime.dlog(bigintBE.encode(key.privateKey)); + const proof = sigmaPropProverRuntime + .withSecrets([secret]) + .signMessage( + secret.publicKey(), + toInt8Array(message.serialize().toBytes()), + proverHintsRuntime.empty() + ); + + return Uint8Array.from(proof); + } + + verify(message: Message, proof: ByteInput, publicKey: ErgoHDKey | Uint8Array): boolean { + try { + const publicKeyBytes = publicKey instanceof Uint8Array ? publicKey : publicKey.publicKey; + const sigmaProp = SigmaProp$.fromPointHex(hex.encode(publicKeyBytes)); + + return sigmaPropVerifierRuntime + .create() + .verifySignature( + sigmaProp, + toInt8Array(messageToBytes(message)), + toInt8Array(ensureBytes(proof)) + ); + } catch { + return false; + } + } +} + +function toInt8Array(bytes: Uint8Array): Int8Array { + return new Int8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength); +} +function messageToBytes(message: Message): Uint8Array { + if (typeof message === "string") { + return isHex(message) ? hex.decode(message) : ErgoMessage.decode(message).serialize().toBytes(); + } + + if (message instanceof ErgoMessage) return message.serialize().toBytes(); + if (message instanceof Uint8Array) return message; + if (message instanceof ErgoUnsignedTransaction) return message.toBytes(); + + return serializeTransaction({ + ...message, + inputs: message.inputs.map((input) => ({ + ...input, + spendingProof: undefined, + extension: + "extension" in input + ? input.extension + : /* v8 ignore next */ + (input.spendingProof?.extension ?? {}) + })) + }).toBytes(); +} + +function flattenTransactionObject(tx: UnsignedTransaction): EIP12UnsignedTransaction { + return tx instanceof ErgoUnsignedTransaction ? tx.toEIP12Object() : tx; +} +function getBurningTokens(tx: EIP12UnsignedTransaction): TokenAmount[] { + const diff = utxoDiff(utxoSum(tx.inputs), utxoSum(tx.outputs)); + + if (diff.tokens.length > 0) { + diff.tokens = diff.tokens.filter((token) => token.tokenId !== tx.inputs[0].boxId); + } + + return diff.tokens; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7a2baced..ca391881 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -182,6 +182,9 @@ importers: '@scure/bip39': specifier: ^2.0.1 version: 2.0.1 + sigmastate-js: + specifier: 0.4.6 + version: 0.4.6 plugins/ageusd: dependencies: