Skip to main content

Example Usage

Where the code lives

The examples in this section come from silent-shard-sdk/wasm-bindings in the examples-hub repository. They are written as runnable tests that execute in a real headless Chromium, so the code is identical to what a web application would write — nothing is stubbed to make the examples work in Node. Each protocol has both an in-memory variant, where the example itself routes every message, and a relay variant that runs over a real WebSocket against the bundled example message relay service.

Prerequisites

  • Node.js ≥ 22 and npm ≥ 11. The project sets engine-strict=true, so npm install fails fast rather than installing against an older toolchain.
  • A Rust toolchain (cargo), only for the relay examples — it builds the example message relay service.

Install

git clone https://github.com/silence-laboratories/examples-hub.git
cd examples-hub/silent-shard-sdk/wasm-bindings

npm install
npx playwright install chromium # one-time: download the headless browser

Load the WASM module before use

Any usage of the package must start with calling an init(). It loads and instantiates the WASM module.

import init from '@silencelaboratories/dkls-wasm';

beforeAll(async () => {
await init();
});

Example keygen without relay

test/keygen.test.js generates a 2-of-3 key with three parties in one browser tab. There is no network: the example holds all three sessions itself and moves every protocol message between them through an in-memory queue, so the whole protocol is visible in one file.

npx vitest run test/keygen.test.js

Identify the run and the parties

An instance id is the unique identifier of a single protocol run; every message in the run is addressed with it, so a fresh one is generated per protocol execution. Each party is identified by an Ed25519 signing key, and its public verifying key is the address other parties send to. In a real deployment each signing key lives on a different device and never leaves it.

const instance = genInstanceId();
const parties = [randomSigningKey(), randomSigningKey(), randomSigningKey()];

Build the setup message

Every protocol run starts from a setup message: the signed configuration that tells each party who is participating, what the threshold is, and how long the run may take. It is built by the initiator and signed with a key the other parties already recognize — setupVk — which is how a party knows the configuration it received is genuine.

The order of addParty calls matters: a party's index in the protocol is the position of its verifying key in that list.

const setupSk = randomSigningKey();
const setupVk = verifyingKey(setupSk);

const builder = new KeygenSetupBuilder();
for (const party of parties) {
builder.addParty(verifyingKey(party), 0);
}

const t = 2; // threshold: 2 parties must cooperate to sign
const ttl = 30; // seconds before the run times out

const setupMsg = builder.build(instance, ttl, t, setupSk);

Create one session per party

The setup message now has to reach every party — in a real deployment over whatever channel you already have; here it is simply passed along in memory. Each party creates its own KeygenSession from it.

The setupVk key used to verify signature over setupMsg should be known to the parties before protocol execution.

const sessions = parties.map((party) => {
const session = new KeygenSession(instance, setupMsg, setupVk, party, null);
return { session, id: verifyingKey(party) };
});

Execute the protocol

MPC protocol runs in rounds: each session produces outgoing messages, consumes the messages addressed to it, and eventually reports that it is done. A session drives itself entirely through these two calls.

Each party starts with creating own output_message. The output_message() returns the next message to send, or undefined when the session has nothing more to say this round. A message carries a payload and a receiver list — receiver(idx) walks it, and a broadcast is simply a message addressed to several parties:

while (shares.length < parties.length) {
// For given party, get all messages to send
for (const slot of sessions) {
if (!slot.session) continue;

let msg;
while ((msg = slot.session.output_message()) !== undefined) {
const body = msg.payload;

let idx = 0;
let receiver;
while ((receiver = msg.receiver(idx)) !== undefined) {
const key = toHex(receiver);
const queue = msgq.get(key) ?? msgq.set(key, []).get(key);
// "Send" the message to other party
queue.push(body);
idx += 1;
}
}
}
...

Now, each party "receives" incoming messages, input_message(msg) feeds one incoming message in, and returns true when that party's protocol has finished and expects no further input:

...

const finished = [];
for (let idx = 0; idx < sessions.length; idx += 1) {
const slot = sessions[idx];
const queue = msgq.get(toHex(slot.id));
if (!queue) continue;

for (const msg of queue.splice(0)) {

// Feed the session with incoming message
const done = slot.session ? slot.session.input_message(msg) : false;
if (done) {
finished.push(idx);
break;
}
}
}

If session reported it's done then, last step for that party is to get the result by calling finish(). In case of keygen - the result is a keyshare.

  // Parties that reported completion hand over their share.
for (const idx of finished) {
shares.push(sessions[idx].session.finish());
sessions[idx].session = null;
}
}

Check the result

All three parties end up with a different share of the same key, so their keyId() — and their public key — agree. Each share is persisted with share.toBytes() and restored with Keyshare.fromBytes(); storing it safely is the application's responsibility.

const keyId = shares[0].keyId();
for (const share of shares) {
expect(share.keyId()).toEqual(keyId);
}

Example keygen with the relay

test/relay-keygen.test.js generates the same 2-of-3 key, but the network is real: each party opens its own WebSocket to a message relay, and init_dkg / join_dkg run the protocol to completion on their own. Nothing in the example routes protocol messages any more — the message-passing loop above disappears entirely.

Start the relay

The relay examples need a message relay to talk to. A minimal one ships with the examples; build and start it in its own shell:

cd msg-relay-svc
cargo run --release -- --listen 127.0.0.1:8080

GET / answers ok, which is a quick way to confirm it is up:

curl http://127.0.0.1:8080/   # -> ok

Then point the example at it and run it:

RELAY_URL=ws://127.0.0.1:8080/v1/msg-relay npx vitest run test/relay-keygen.test.js

Build the setup message

Instance id, party keys, and the setup message are built exactly as in the non-relay example.

const instance = genInstanceId();
const parties = [randomSigningKey(), randomSigningKey(), randomSigningKey()];

const setupSk = randomSigningKey();
const setupVk = verifyingKey(setupSk);

const builder = new KeygenSetupBuilder();
for (const party of parties) {
builder.addParty(verifyingKey(party), 0);
}
const setupMsg = builder.build(instance, ttl, t, setupSk);

Publish the setup message

The setup message will reach the parties by the relayer:

const abort = new AbortController();
const client = await msg_relay_connect(RELAY_URL, abort.signal);
client.send(setupMsg);

Run the protocol

One party initiates and the others join:

  • init_dkg is for the party that already holds the setup message — the initiator. It publishes it to the other parties and starts its own protocol run.
  • join_dkg is for the others: they fetch the setup message from the relay by id. Because they receive a configuration rather than choose it, they get a validator callback with the decoded KeygenSetup and can reject the run by returning false or throwing. Returning true proceeds with keygen.

Both take a 32-byte seed for the protocol's CSPRNG, and both resolve to the party's Keyshare — the entire protocol is a single await per party.

const shares = await Promise.all([
init_dkg(
instance,
setupMsg, // this party already has the setup message
setupVk,
parties[0], // this party's identity
RELAY_URL,
randomSeed(),
null, // an existing share, for a refresh; none here
),

// Joining parties don't pass the setup message — it is fetched by id.
...parties.slice(1).map((party) =>
join_dkg(
instance,
setupVk,
party,
RELAY_URL,
randomSeed(),
async (setup) => {
// Validate the configuration before taking part in the run.
expect(setup.threshold()).toBe(t);
expect(setup.participants()).toBe(parties.length);
return true;
},
),
),
]);

The result is the same as before: three shares of one key, agreeing on keyId() and publicKey().

const keyId = shares[0].keyId();
for (const share of shares) {
expect(share.keyId()).toEqual(keyId);
expect(toHex(share.publicKey())).toEqual(toHex(shares[0].publicKey()));
}

Other examples

Other operations, like signing, quorum change, etc follow the same flow. The setup message changes per operation. Review our examples silent-shard-sdk/wasm-bindings. To find out the details.