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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/bright-sigmas-prove.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@fleet-sdk/wallet": minor
---

Add a SigmaJS-backed prover for smart-contract transaction signing and Sigma proposition message proofs.
19 changes: 19 additions & 0 deletions packages/wallet/README.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion packages/wallet/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions packages/wallet/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./ergoHDKey";
export * from "./mnemonic";
export * from "./prover/prover";
export * from "./prover/sigmaJsProver";
168 changes: 168 additions & 0 deletions packages/wallet/src/prover/sigmaJsProver.spec.ts
Original file line number Diff line number Diff line change
@@ -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<SigmaJsProverOptions>): 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"
);
});
});
175 changes: 175 additions & 0 deletions packages/wallet/src/prover/sigmaJsProver.ts
Original file line number Diff line number Diff line change
@@ -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<typeof ProverBuilder$.create>[0];
type SigmaJsBuilder = ReturnType<typeof ProverBuilder$.create>;
type SigmaJsTransactionProver = ReturnType<SigmaJsBuilder["build"]>;
type SigmaJsBlockchainStateContextInternal = Parameters<SigmaJsTransactionProver["reduce"]>[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<bigint>[] {
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;
}
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.