Aleo Signing with EdBLS12-377
The EdBls12377 class provides threshold signing for the Aleo network using the
frost-ed-bls12-377 MPC protocol. As with every Sodot scheme, the private key is never reconstructed —
it is split into t-of-n shares and signing happens collaboratively between a threshold of parties.
Aleo signing differs from the other schemes in two important ways:
- A key produces an Aleo
computeKeyandaddress(rather than a single raw public key), and the same key can extract an Aleo view key for decrypting records. - Instead of signing an opaque message hash, you usually sign the fields of an Aleo execution request.
Arbitrary byte messages are also supported through
signBytes.
Derivation is not available for ed-bls12-377 keys — each key corresponds to a single Aleo address.
Key Generation
Generate a threshold computeKey/address pair. As with the other schemes, the room must be created on
your server using your API_KEY, and every party exchanges its keygenId over an authenticated channel
before joining the keygen room.
import { EdBls12377 } from '@sodot/sodot-node-sdk';
const N = 3;
const T = 2;
const edBls12377 = new EdBls12377();
const API_KEY = 'MY_API_KEY';
// Your server side creates a room for 3 parties using its API_KEY
const keygenRoomUuid = await edBls12377.createRoom(N, API_KEY);
// All parties call initKeygen to get an EdBls12377InitKeygenResult, that contains a keygenId
const keygenInitResult = await edBls12377.initKeygen();
// All parties receive the keygenIds from all other parties over an authenticated channel
const keygenIds = [keygenId1, keygenId2];
// All parties join the keygen room
const keygenResult = await edBls12377.keygen(keygenRoomUuid, N, T, keygenInitResult, keygenIds);
// Discard the keygenInitResult object at this point
// keygenResult now holds { computeKey, address, secretShare }
Full details can be found here.
Signing an Aleo Request
signRequest signs the fields of an Aleo execution request with a threshold subset of parties. The payload
is an EdBls12377RequestSignPayload:
function_id— the identifier of the function being executed.is_root— whether this is the root request of the execution.program_checksum— the program checksum, ornull.inputs— oneEdBls12377RequestInputper request input.
Each input is one of two shapes. Non-record inputs use { type, index, fields }, where type is one of
'constant', 'public', 'private', or 'external_record'. Record inputs use { type: 'record', h, tag }.
All request fields are passed as Uint8Array; the SDK hex-encodes them before calling the native bindings.
import { EdBls12377 } from '@sodot/sodot-node-sdk';
const T = 2;
const edBls12377 = new EdBls12377();
const API_KEY = 'MY_API_KEY';
const bytes32 = (value: number) => Uint8Array.from([value, ...new Uint8Array(31)]);
// A signing room is created on your server for T parties
const signingRoomUuid = await edBls12377.createRoom(T, API_KEY);
const payload = {
function_id: bytes32(0x29),
is_root: false,
program_checksum: bytes32(0x0b),
inputs: [{ type: 'constant', index: bytes32(0x00), fields: [bytes32(0x01)] }],
};
// T parties join the signing room
const signedRequest = await edBls12377.signRequest(signingRoomUuid, keygenResult, payload);
// signedRequest holds byte-encoded { signer, signature, tvk, gammas }
The result is an EdBls12377SignedRequest containing the
byte-encoded signer, signature, tvk, and gammas. See the end-to-end example
below for how these fields are fed into the Provable SDK to build and broadcast a transaction.
Signing Arbitrary Bytes
signBytes produces an Aleo Schnorr signature over a raw byte message, matching snarkVM's
Signature::sign_bytes / Signature::verify_bytes. The signature is returned as a bech32 sign1... string
(parseable by snarkVM's Signature::from_str) and verifies against the threshold key's address.
const message = new TextEncoder().encode('hello aleo');
const signature = await edBls12377.signBytes(signingRoomUuid, keygenResult, message);
// signature is a bech32-encoded Aleo Signature string, e.g. "sign1..."
The message is bit-packed into 252-bit field-element chunks before signing; messages up to ~128 KiB are supported. Signing fails before any MPC traffic if the message is too large, or if its packed form structurally matches an Aleo request preimage (a cross-protocol confusion guard).
View Keys and Key Export
Beyond exporting the full private key, ed-bls12-377 keys can also export an Aleo view key, which is
used to decrypt records owned by the address. Both can be exported either to a single designated party
through the relay server, or reconstructed locally from a threshold of shares (offline recovery).
// Online: export to a single designated party. The recipient first publishes its export ID.
const exportTo = await edBls12377.exportID(keygenResult);
const viewKey = await edBls12377.exportViewKey(viewKeyExportRoomUuid, keygenResult, exportTo);
const fullKey = await edBls12377.exportFullPrivateKey(fullKeyExportRoomUuid, keygenResult, exportTo);
// Every party except the recipient receives `undefined`.
// Offline: reconstruct locally from a threshold-sized array of key shares (no relay server).
const viewKeyOffline = await edBls12377.offlineExportViewKey([keygenResult1, keygenResult2]);
const fullKeyOffline = await edBls12377.offlineExportFullPrivateKey([keygenResult1, keygenResult2]);
Refresh, Resharing, and Import
ed-bls12-377 keys support the same lifecycle operations as the other schemes:
refresh to rotate the secret shares without changing the
computeKey/address, resharing to move the key to a new
quorum, and importing an externally generated key.
Resharing and key import are advanced features. Please consult with the Sodot team before using them — see the API reference for the full safety requirements.
End-to-End: Submitting an Aleo Transaction
This example shows the full flow of building, signing, and broadcasting an Aleo transaction using
the threshold key together with the Provable SDK. The helper
toRequestSignPayload maps the Provable SDK's signing inputs into the signRequest payload shape.
import { EdBls12377 } from '@sodot/sodot-node-sdk';
import {
Address,
ProgramManager,
ViewKey,
buildExecutionRequestFromExternallySignedData,
computeExternalSigningInputs,
} from '@provablehq/sdk/testnet.js';
const TEST_URL = 'us1-test.sodot.dev';
const API_KEY = 'MY_API_KEY';
const PROVER_URI = 'https://accelerate.provable.com';
const programName = 'credits.aleo';
const functionName = 'transfer_public_to_private';
const inputs = ['aleo1recipient...', '1u64'];
const inputTypes = ['address.private', 'u64.public'];
const seedHex = '...'; // 32-byte Aleo seed hex
function toRequestSignPayload(signingInputs) {
return {
function_id: signingInputs.functionId,
is_root: signingInputs.isRoot,
program_checksum: signingInputs.checksum ?? null,
inputs: signingInputs.requestInputs.map((requestInput) => {
if (requestInput.signingInputType === 'record') {
if (!requestInput.h || !requestInput.tag) {
throw new Error('Record input missing h/tag. Provide viewKey when computing record signing inputs.');
}
return { type: 'record', h: requestInput.h, tag: requestInput.tag };
}
return {
type: requestInput.signingInputType,
index: requestInput.index,
fields: requestInput.data,
};
}),
};
}
const edBls12377 = new EdBls12377(TEST_URL);
// Import an existing Aleo seed into a 1-of-1 threshold key (any t-of-n setup also works).
const importRoomUuid = await edBls12377.createRoom(1, API_KEY);
const importerInit = await edBls12377.initKeygen();
const imported = await edBls12377.importPrivateKeyImporter(importRoomUuid, 1, seedHex, importerInit, [], false);
// Derive the address from the exported view key.
const viewKey = await edBls12377.offlineExportViewKey([imported]);
const signer = Address.from_view_key(ViewKey.from_string(viewKey)).to_string();
// Compute the request signing inputs. `viewKey` is optional when there are no record inputs;
// providing it also derives `signer` and `skTag`.
const signingInputs = await computeExternalSigningInputs({
programName,
functionName,
inputs,
inputTypes,
isRoot: true,
checksum: null,
viewKey,
outputFormat: 'bytes',
});
if (!signingInputs.skTag) {
throw new Error('Expected skTag when viewKey is provided.');
}
// Threshold-sign the request.
const signingRoomUuid = await edBls12377.createRoom(1, API_KEY);
const signedRequest = await edBls12377.signRequest(signingRoomUuid, imported, toRequestSignPayload(signingInputs));
// Reconstruct the execution request from the externally signed data.
const executionRequest = buildExecutionRequestFromExternallySignedData({
programId: programName,
functionName,
inputs,
inputTypes,
signature: signedRequest.signature,
tvk: signedRequest.tvk,
signer: signedRequest.signer,
skTag: signingInputs.skTag,
});
const programManager = new ProgramManager();
programManager.networkClient.setProverUri(PROVER_URI);
const provingRequest = await programManager.provingRequest({
programName,
functionName,
inputs,
priorityFee: 0,
privateFee: false,
broadcast: true,
executionRequest,
});
const provingResult = await programManager.networkClient.submitProvingRequest({
provingRequest,
dpsPrivacy: true,
});
console.log('Signer:', signer);
console.log('Transaction ID:', provingResult.transaction.id);
buildExecutionRequestFromExternallySignedData supports three reconstruction strategies depending on your
privacy model — { viewKey, gammas }, { recordViewKeys, gammas }, or { inputIds }. In all cases the
payload sent to signRequest still uses { type: 'record', h, tag } for record inputs. See the
EdBls12377 API reference for the full method documentation.