Ring Signatures (LSAG) Precompile
Native EVM precompile for Linkable Spontaneous Anonymous Group signatures for Q-Chain privacy
Abstract
This LP specifies a native EVM precompile for Linkable Spontaneous Anonymous Group (LSAG) signatures. Ring signatures allow a member of a group to sign a message such that it can be verified as coming from someone in the group, but without revealing which member actually signed. The linkability property enables detection of double-spending while preserving anonymity. The precompile is deployed at address 0x031C and supports both classical secp256k1-based LSAG and post-quantum lattice-based ring signatures for Q-Chain privacy transactions.
Motivation
Privacy-Preserving Transactions
For Q-Chain (Lux's privacy-focused chain), ring signatures are essential:
- Sender Anonymity: Hide which address sent a transaction among a set of decoys
- Unlinkability: Multiple transactions from the same sender appear unrelated
- Double-Spend Prevention: Key images enable detection of double-spending
- No Trusted Setup: Unlike zk-SNARKs, no trusted setup ceremony required
Use Cases
| Use Case | Description | Scheme |
|---|---|---|
| Private Transfers | Hide sender in Q-Chain transactions | LSAG |
| Anonymous Voting | Prove membership without revealing identity | LSAG |
| Whistleblower Systems | Sign documents anonymously | LSAG |
| Private Staking | Stake without revealing validator identity | Lattice-LSAG |
| Confidential DeFi | Private swaps and liquidity provision | LSAG |
Comparison with Other Privacy Technologies
| Technology | Sender Privacy | Receiver Privacy | Amount Privacy | Double-Spend |
|---|---|---|---|---|
| Ring Signatures | ✅ | ❌ | ❌ | ✅ Key Images |
| Stealth Addresses | ❌ | ✅ | ❌ | N/A |
| Confidential Tx | ❌ | ❌ | ✅ | Bulletproofs |
| Full Privacy | ✅ All three (combine above) |
Specification
Precompile Address
RING_SIGNATURE_PRECOMPILE = 0x031C
Supported Schemes
| ID | Scheme | Curve/Lattice | Security |
|---|---|---|---|
0x01 | LSAG | secp256k1 | 128-bit |
0x02 | LSAG | Ed25519 | 128-bit |
0x03 | DualRing | secp256k1 | 128-bit |
0x10 | Lattice-LSAG | Module-LWE | Post-quantum |
Operation Selectors
| Selector | Operation | Description |
|---|---|---|
0x01 | Sign | Create ring signature |
0x02 | Verify | Verify ring signature |
0x03 | VerifyKeyImage | Check if key image was used |
0x04 | ComputeKeyImage | Compute key image for a private key |
0x10 | BatchVerify | Verify multiple signatures efficiently |
Key Image
The key image is a deterministic value derived from the signer's private key:
KeyImage = x * HashToPoint(P)
Where:
xis the private keyPis the public keyHashToPointmaps the public key to a curve point
Key images enable:
- Double-Spend Detection: Same key = same key image
- Anonymity: Key image doesn't reveal which ring member signed
Input Format
Sign
┌────────┬─────────┬────────┬─────────────────────────────────────────┬───────────────┬────────────────┐
│ 1 byte │ 1 byte │ 1 byte │ Variable │ 32 bytes │ Variable │
│ 0x01 │ scheme │ n_ring │ ring_pubkeys[n_ring * pubkey_size] │ signer_sk │ message │
└────────┴─────────┴────────┴─────────────────────────────────────────┴───────────────┴────────────────┘
Ring Pubkeys Format:
pubkey[0] || pubkey[1] || ... || pubkey[n-1]
Each pubkey is:
- 33 bytes (compressed) or 65 bytes (uncompressed) for secp256k1
- 32 bytes for Ed25519
- Variable for lattice
Output:
┌───────────────┬────────────────────────────────────────┬────────────────┐
│ Key Image │ c[n] (challenges) │ s[n] (responses)│
│ 33 bytes │ n * 32 bytes │ n * 32 bytes │
└───────────────┴────────────────────────────────────────┴────────────────┘
Verify
┌────────┬─────────┬────────┬─────────────────────────────────────────┬───────────────┬────────────────┐
│ 1 byte │ 1 byte │ 1 byte │ Variable │ Variable │ Variable │
│ 0x02 │ scheme │ n_ring │ ring_pubkeys[n_ring * pubkey_size] │ signature │ message │
└────────┴─────────┴────────┴─────────────────────────────────────────┴───────────────┴────────────────┘
Output: 0x01 (valid) or 0x00 (invalid)
ComputeKeyImage
┌────────┬─────────┬────────────────┐
│ 1 byte │ 1 byte │ 32 bytes │
│ 0x04 │ scheme │ private_key │
└────────┴─────────┴────────────────┘
Output: key_image (33 bytes for secp256k1)
BatchVerify
┌────────┬─────────┬─────────┬───────────────────────────────────────────────┐
│ 1 byte │ 1 byte │ 2 bytes │ Variable │
│ 0x10 │ scheme │ n_sigs │ [ring_size, ring[], signature, message]... │
└────────┴─────────┴─────────┴───────────────────────────────────────────────┘
Output: bitmap where bit i = 1 if signature i is valid
Gas Costs
LSAG (secp256k1)
| Operation | Base Gas | Per Ring Member | Per Byte |
|---|---|---|---|
| Sign | 5,000 | 3,000 | 5 |
| Verify | 4,000 | 2,500 | 5 |
| ComputeKeyImage | 3,000 | - | - |
| BatchVerify | 3,000 | 2,000 | 5 |
LSAG (Ed25519)
| Operation | Base Gas | Per Ring Member | Per Byte |
|---|---|---|---|
| Sign | 4,000 | 2,000 | 5 |
| Verify | 3,000 | 1,500 | 5 |
| ComputeKeyImage | 2,000 | - | - |
Lattice-LSAG (Post-Quantum)
| Operation | Base Gas | Per Ring Member | Per Byte |
|---|---|---|---|
| Sign | 50,000 | 10,000 | 10 |
| Verify | 40,000 | 8,000 | 10 |
| ComputeKeyImage | 10,000 | - | - |
Gas Formula
sign_gas = base_gas + (ring_size * per_member) + (message_length * per_byte)
verify_gas = base_gas + (ring_size * per_member) + (message_length * per_byte)
batch_verify_gas = base_gas + sum(per_sig_gas) * 0.8 // 20% batch discount
Solidity Interface
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
/// @title IRingSignature - Ring Signature Precompile Interface
/// @notice Native support for LSAG and Lattice ring signatures
/// @dev Deployed at address 0x031C
interface IRingSignature {
// ============ Scheme Identifiers ============
uint8 constant SCHEME_LSAG_SECP256K1 = 0x01;
uint8 constant SCHEME_LSAG_ED25519 = 0x02;
uint8 constant SCHEME_DUAL_RING = 0x03;
uint8 constant SCHEME_LATTICE_LSAG = 0x10;
// ============ Core Functions ============
/// @notice Create a ring signature
/// @param scheme The signature scheme to use
/// @param ring Array of public keys forming the ring
/// @param signerSk The signer's private key
/// @param signerIndex Index of signer in the ring
/// @param message Message to sign
/// @return signature The ring signature
/// @return keyImage The key image for double-spend detection
function sign(
uint8 scheme,
bytes[] calldata ring,
bytes calldata signerSk,
uint8 signerIndex,
bytes calldata message
) external view returns (bytes memory signature, bytes memory keyImage);
/// @notice Verify a ring signature
/// @param scheme The signature scheme used
/// @param ring Array of public keys forming the ring
/// @param signature The ring signature to verify
/// @param message The signed message
/// @return valid True if signature is valid
/// @return keyImage The extracted key image
function verify(
uint8 scheme,
bytes[] calldata ring,
bytes calldata signature,
bytes calldata message
) external view returns (bool valid, bytes memory keyImage);
/// @notice Compute key image for a private key
/// @param scheme The signature scheme
/// @param privateKey The private key
/// @return keyImage The deterministic key image
function computeKeyImage(
uint8 scheme,
bytes calldata privateKey
) external view returns (bytes memory keyImage);
/// @notice Verify multiple signatures efficiently
/// @param scheme The signature scheme
/// @param rings Array of rings (one per signature)
/// @param signatures Array of signatures
/// @param messages Array of messages
/// @return validBitmap Bitmap where bit i = validity of signature i
function batchVerify(
uint8 scheme,
bytes[][] calldata rings,
bytes[] calldata signatures,
bytes[] calldata messages
) external view returns (uint256 validBitmap);
}
/// @title RingSignature - Library for ring signature operations
library RingSignature {
address constant PRECOMPILE = address(0x031C);
error SigningFailed();
error VerificationFailed();
error InvalidRingSize();
error InvalidSignerIndex();
/// @notice Minimum ring size for meaningful anonymity
uint8 constant MIN_RING_SIZE = 2;
/// @notice Maximum ring size (gas limit constraint)
uint8 constant MAX_RING_SIZE = 64;
/// @notice Create LSAG ring signature with secp256k1
/// @param ring Array of compressed public keys (33 bytes each)
/// @param signerSk Signer's private key (32 bytes)
/// @param signerIndex Position of signer in ring
/// @param message Message to sign
/// @return signature The ring signature
/// @return keyImage The key image
function signLSAG(
bytes[] memory ring,
bytes memory signerSk,
uint8 signerIndex,
bytes memory message
) internal view returns (bytes memory signature, bytes memory keyImage) {
require(ring.length >= MIN_RING_SIZE, "Ring too small");
require(ring.length <= MAX_RING_SIZE, "Ring too large");
require(signerIndex < ring.length, "Invalid signer index");
// Encode ring as contiguous bytes
bytes memory ringBytes = encodeRing(ring);
bytes memory input = abi.encodePacked(
uint8(0x01), // Sign
uint8(0x01), // LSAG secp256k1
uint8(ring.length),
ringBytes,
signerSk,
uint8(signerIndex),
message
);
(bool success, bytes memory result) = PRECOMPILE.staticcall(input);
if (!success) revert SigningFailed();
// Parse result: keyImage (33 bytes) || signature
keyImage = new bytes(33);
for (uint i = 0; i < 33; i++) {
keyImage[i] = result[i];
}
signature = new bytes(result.length - 33);
for (uint i = 0; i < signature.length; i++) {
signature[i] = result[i + 33];
}
}
/// @notice Verify LSAG ring signature
/// @param ring Array of public keys
/// @param signature The ring signature
/// @param message The signed message
/// @return valid True if valid
/// @return keyImage The key image from signature
function verifyLSAG(
bytes[] memory ring,
bytes memory signature,
bytes memory message
) internal view returns (bool valid, bytes memory keyImage) {
bytes memory ringBytes = encodeRing(ring);
bytes memory input = abi.encodePacked(
uint8(0x02), // Verify
uint8(0x01), // LSAG secp256k1
uint8(ring.length),
ringBytes,
signature,
message
);
(bool success, bytes memory result) = PRECOMPILE.staticcall(input);
if (!success) return (false, "");
valid = result[0] == 0x01;
// Extract key image from signature (first 33 bytes)
keyImage = new bytes(33);
for (uint i = 0; i < 33; i++) {
keyImage[i] = signature[i];
}
}
/// @notice Check if a key image has been used (double-spend detection)
/// @dev This is a view function - actual storage is chain-specific
function extractKeyImage(
bytes memory signature
) internal pure returns (bytes memory keyImage) {
keyImage = new bytes(33);
for (uint i = 0; i < 33; i++) {
keyImage[i] = signature[i];
}
}
/// @notice Compute key image from private key
function computeKeyImage(
bytes memory privateKey
) internal view returns (bytes memory) {
bytes memory input = abi.encodePacked(
uint8(0x04), // ComputeKeyImage
uint8(0x01), // LSAG secp256k1
privateKey
);
(bool success, bytes memory result) = PRECOMPILE.staticcall(input);
if (!success) revert VerificationFailed();
return result;
}
/// @notice Encode ring of public keys as contiguous bytes
function encodeRing(bytes[] memory ring) internal pure returns (bytes memory) {
uint totalLen = 0;
for (uint i = 0; i < ring.length; i++) {
totalLen += ring[i].length;
}
bytes memory result = new bytes(totalLen);
uint offset = 0;
for (uint i = 0; i < ring.length; i++) {
for (uint j = 0; j < ring[i].length; j++) {
result[offset + j] = ring[i][j];
}
offset += ring[i].length;
}
return result;
}
}
/// @title KeyImageRegistry - Track used key images
/// @notice Prevents double-spending by recording used key images
contract KeyImageRegistry {
mapping(bytes32 => bool) public usedKeyImages;
event KeyImageUsed(bytes32 indexed keyImageHash, bytes keyImage);
error KeyImageAlreadyUsed();
/// @notice Check and record a key image
/// @param keyImage The key image to check/record
function useKeyImage(bytes calldata keyImage) external {
bytes32 hash = keccak256(keyImage);
if (usedKeyImages[hash]) revert KeyImageAlreadyUsed();
usedKeyImages[hash] = true;
emit KeyImageUsed(hash, keyImage);
}
/// @notice Check if a key image has been used
function isUsed(bytes calldata keyImage) external view returns (bool) {
return usedKeyImages[keccak256(keyImage)];
}
}
Go Implementation
// Package ring implements the ring signature precompile for Lux EVM
package ring
import (
"crypto/rand"
"crypto/sha256"
"errors"
"math/big"
"github.com/luxfi/crypto/secp256k1"
)
const (
PrecompileAddress = 0x031C
// Operation selectors
OpSign = 0x01
OpVerify = 0x02
OpVerifyKeyImage = 0x03
OpComputeKeyImage = 0x04
OpBatchVerify = 0x10
// Scheme IDs
SchemeLSAGSecp256k1 = 0x01
SchemeLSAGEd25519 = 0x02
SchemeDualRing = 0x03
SchemeLatticeLSAG = 0x10
)
// Gas costs
const (
GasSignBase = 5000
GasSignPerMember = 3000
GasVerifyBase = 4000
GasVerifyPerMember = 2500
GasComputeKeyImage = 3000
GasBatchVerifyBase = 3000
GasBatchDiscount = 80 // 80% of individual cost
GasPerByte = 5
)
var (
ErrInvalidInput = errors.New("invalid ring signature input")
ErrInvalidScheme = errors.New("invalid signature scheme")
ErrInvalidRingSize = errors.New("ring size must be >= 2")
ErrInvalidSignerIdx = errors.New("signer index out of bounds")
ErrInvalidSignature = errors.New("invalid ring signature")
ErrInvalidPublicKey = errors.New("invalid public key in ring")
)
// RingSignaturePrecompile implements the ring signature precompile
type RingSignaturePrecompile struct{}
// RequiredGas calculates gas for ring signature operations
func (p *RingSignaturePrecompile) RequiredGas(input []byte) uint64 {
if len(input) < 3 {
return 0
}
op := input[0]
scheme := input[1]
var baseGas, perMemberGas uint64
switch scheme {
case SchemeLSAGSecp256k1:
baseGas = GasSignBase
perMemberGas = GasSignPerMember
case SchemeLSAGEd25519:
baseGas = GasSignBase - 1000
perMemberGas = GasSignPerMember - 1000
case SchemeLatticeLSAG:
baseGas = 50000
perMemberGas = 10000
default:
return 0
}
switch op {
case OpSign:
ringSize := int(input[2])
msgLen := len(input) - 3 - ringSize*33 - 32 - 1
if msgLen < 0 {
msgLen = 0
}
return baseGas + uint64(ringSize)*perMemberGas + uint64(msgLen)*GasPerByte
case OpVerify:
ringSize := int(input[2])
return (baseGas-1000) + uint64(ringSize)*(perMemberGas-500)
case OpComputeKeyImage:
return GasComputeKeyImage
case OpBatchVerify:
// Simplified: base + estimated per-sig
numSigs := int(input[2])<<8 | int(input[3])
return GasBatchVerifyBase + uint64(numSigs)*5000*GasBatchDiscount/100
default:
return 0
}
}
// Run executes the ring signature precompile
func (p *RingSignaturePrecompile) Run(input []byte) ([]byte, error) {
if len(input) < 3 {
return nil, ErrInvalidInput
}
op := input[0]
scheme := input[1]
switch op {
case OpSign:
return p.sign(scheme, input[2:])
case OpVerify:
return p.verify(scheme, input[2:])
case OpComputeKeyImage:
return p.computeKeyImage(scheme, input[2:])
case OpBatchVerify:
return p.batchVerify(scheme, input[2:])
default:
return nil, ErrInvalidInput
}
}
// LSAGSignature represents an LSAG ring signature
type LSAGSignature struct {
KeyImage []byte // 33 bytes
C []*big.Int // n challenges
S []*big.Int // n responses
}
// sign creates an LSAG ring signature
func (p *RingSignaturePrecompile) sign(scheme byte, input []byte) ([]byte, error) {
if scheme != SchemeLSAGSecp256k1 {
return nil, ErrInvalidScheme
}
if len(input) < 1 {
return nil, ErrInvalidInput
}
ringSize := int(input[0])
if ringSize < 2 {
return nil, ErrInvalidRingSize
}
offset := 1
// Parse ring public keys (33 bytes each, compressed)
ring := make([][]byte, ringSize)
for i := 0; i < ringSize; i++ {
if len(input) < offset+33 {
return nil, ErrInvalidInput
}
ring[i] = input[offset : offset+33]
offset += 33
}
// Parse signer's private key (32 bytes)
if len(input) < offset+32 {
return nil, ErrInvalidInput
}
signerSk := input[offset : offset+32]
offset += 32
// Parse signer index (1 byte)
if len(input) < offset+1 {
return nil, ErrInvalidInput
}
signerIdx := int(input[offset])
offset++
if signerIdx >= ringSize {
return nil, ErrInvalidSignerIdx
}
// Message is the rest
message := input[offset:]
// Create LSAG signature
sig, err := lsagSign(ring, signerSk, signerIdx, message)
if err != nil {
return nil, err
}
return sig.Serialize(), nil
}
// verify verifies an LSAG ring signature
func (p *RingSignaturePrecompile) verify(scheme byte, input []byte) ([]byte, error) {
if scheme != SchemeLSAGSecp256k1 {
return nil, ErrInvalidScheme
}
ringSize := int(input[0])
offset := 1
// Parse ring
ring := make([][]byte, ringSize)
for i := 0; i < ringSize; i++ {
ring[i] = input[offset : offset+33]
offset += 33
}
// Signature: keyImage (33) + c[n] (32 each) + s[n] (32 each)
sigLen := 33 + ringSize*32 + ringSize*32
signature := input[offset : offset+sigLen]
offset += sigLen
message := input[offset:]
// Parse and verify signature
sig, err := parseLSAGSignature(signature, ringSize)
if err != nil {
return []byte{0x00}, nil
}
valid := lsagVerify(ring, sig, message)
if valid {
return []byte{0x01}, nil
}
return []byte{0x00}, nil
}
// computeKeyImage computes the key image for a private key
func (p *RingSignaturePrecompile) computeKeyImage(scheme byte, input []byte) ([]byte, error) {
if scheme != SchemeLSAGSecp256k1 {
return nil, ErrInvalidScheme
}
if len(input) < 32 {
return nil, ErrInvalidInput
}
privateKey := input[:32]
return computeKeyImageSecp256k1(privateKey)
}
// LSAG implementation using secp256k1
func lsagSign(ring [][]byte, signerSk []byte, signerIdx int, message []byte) (*LSAGSignature, error) {
n := len(ring)
curve := secp256k1.S256()
// Parse signer's private key
x := new(big.Int).SetBytes(signerSk)
// Get signer's public key
pubX, pubY := curve.ScalarBaseMult(x.Bytes())
signerPk := secp256k1.CompressPubkey(pubX, pubY)
// Compute key image: I = x * H(P)
hp := hashToPoint(signerPk)
imgX, imgY := curve.ScalarMult(hp.X, hp.Y, x.Bytes())
keyImage := secp256k1.CompressPubkey(imgX, imgY)
// Initialize arrays
c := make([]*big.Int, n)
s := make([]*big.Int, n)
// Generate random alpha
alpha, _ := rand.Int(rand.Reader, curve.Params().N)
// L = alpha * G
Lx, Ly := curve.ScalarBaseMult(alpha.Bytes())
// R = alpha * H(P)
Rx, Ry := curve.ScalarMult(hp.X, hp.Y, alpha.Bytes())
// c[signerIdx+1] = H(m, L, R)
nextIdx := (signerIdx + 1) % n
c[nextIdx] = hashRing(message, Lx, Ly, Rx, Ry)
// Generate random s[i] for i != signerIdx and compute c[i]
for i := 1; i < n; i++ {
idx := (signerIdx + i) % n
// Generate random s[idx]
s[idx], _ = rand.Int(rand.Reader, curve.Params().N)
// Parse P[idx]
pkX, pkY := secp256k1.DecompressPubkey(ring[idx])
if pkX == nil {
return nil, ErrInvalidPublicKey
}
// L = s[idx] * G + c[idx] * P[idx]
sGx, sGy := curve.ScalarBaseMult(s[idx].Bytes())
cPx, cPy := curve.ScalarMult(pkX, pkY, c[idx].Bytes())
Lx, Ly = curve.Add(sGx, sGy, cPx, cPy)
// R = s[idx] * H(P[idx]) + c[idx] * I
hpIdx := hashToPoint(ring[idx])
sHx, sHy := curve.ScalarMult(hpIdx.X, hpIdx.Y, s[idx].Bytes())
cIx, cIy := curve.ScalarMult(imgX, imgY, c[idx].Bytes())
Rx, Ry = curve.Add(sHx, sHy, cIx, cIy)
// c[(idx+1) % n] = H(m, L, R)
nextIdx := (idx + 1) % n
if nextIdx != signerIdx {
c[nextIdx] = hashRing(message, Lx, Ly, Rx, Ry)
}
}
// s[signerIdx] = alpha - c[signerIdx] * x mod n
s[signerIdx] = new(big.Int).Mul(c[signerIdx], x)
s[signerIdx].Mod(s[signerIdx], curve.Params().N)
s[signerIdx].Sub(alpha, s[signerIdx])
s[signerIdx].Mod(s[signerIdx], curve.Params().N)
return &LSAGSignature{
KeyImage: keyImage,
C: c,
S: s,
}, nil
}
func lsagVerify(ring [][]byte, sig *LSAGSignature, message []byte) bool {
n := len(ring)
curve := secp256k1.S256()
// Parse key image
imgX, imgY := secp256k1.DecompressPubkey(sig.KeyImage)
if imgX == nil {
return false
}
// Verify ring
cPrev := sig.C[0]
for i := 0; i < n; i++ {
// Parse P[i]
pkX, pkY := secp256k1.DecompressPubkey(ring[i])
if pkX == nil {
return false
}
// L = s[i] * G + c[i] * P[i]
sGx, sGy := curve.ScalarBaseMult(sig.S[i].Bytes())
cPx, cPy := curve.ScalarMult(pkX, pkY, cPrev.Bytes())
Lx, Ly := curve.Add(sGx, sGy, cPx, cPy)
// R = s[i] * H(P[i]) + c[i] * I
hp := hashToPoint(ring[i])
sHx, sHy := curve.ScalarMult(hp.X, hp.Y, sig.S[i].Bytes())
cIx, cIy := curve.ScalarMult(imgX, imgY, cPrev.Bytes())
Rx, Ry := curve.Add(sHx, sHy, cIx, cIy)
// c[i+1] = H(m, L, R)
cNext := hashRing(message, Lx, Ly, Rx, Ry)
if i == n-1 {
// Check c[0] == computed c[n]
return cNext.Cmp(sig.C[0]) == 0
}
cPrev = cNext
}
return false
}
func hashToPoint(pk []byte) *secp256k1.Point {
// Hash public key and convert to curve point
h := sha256.Sum256(pk)
x, y := secp256k1.S256().ScalarBaseMult(h[:])
return &secp256k1.Point{X: x, Y: y}
}
func hashRing(msg []byte, Lx, Ly, Rx, Ry *big.Int) *big.Int {
h := sha256.New()
h.Write(msg)
h.Write(Lx.Bytes())
h.Write(Ly.Bytes())
h.Write(Rx.Bytes())
h.Write(Ry.Bytes())
return new(big.Int).SetBytes(h.Sum(nil))
}
func computeKeyImageSecp256k1(privateKey []byte) ([]byte, error) {
curve := secp256k1.S256()
x := new(big.Int).SetBytes(privateKey)
// Get public key
pubX, pubY := curve.ScalarBaseMult(x.Bytes())
pk := secp256k1.CompressPubkey(pubX, pubY)
// Key image = x * H(P)
hp := hashToPoint(pk)
imgX, imgY := curve.ScalarMult(hp.X, hp.Y, x.Bytes())
return secp256k1.CompressPubkey(imgX, imgY), nil
}
func (sig *LSAGSignature) Serialize() []byte {
n := len(sig.C)
// keyImage (33) + c[n] (32 each) + s[n] (32 each)
result := make([]byte, 33+n*64)
copy(result, sig.KeyImage)
offset := 33
for i := 0; i < n; i++ {
copy(result[offset:], sig.C[i].FillBytes(make([]byte, 32)))
offset += 32
}
for i := 0; i < n; i++ {
copy(result[offset:], sig.S[i].FillBytes(make([]byte, 32)))
offset += 32
}
return result
}
func parseLSAGSignature(data []byte, ringSize int) (*LSAGSignature, error) {
expectedLen := 33 + ringSize*64
if len(data) < expectedLen {
return nil, ErrInvalidSignature
}
sig := &LSAGSignature{
KeyImage: data[:33],
C: make([]*big.Int, ringSize),
S: make([]*big.Int, ringSize),
}
offset := 33
for i := 0; i < ringSize; i++ {
sig.C[i] = new(big.Int).SetBytes(data[offset : offset+32])
offset += 32
}
for i := 0; i < ringSize; i++ {
sig.S[i] = new(big.Int).SetBytes(data[offset : offset+32])
offset += 32
}
return sig, nil
}
func (p *RingSignaturePrecompile) batchVerify(scheme byte, input []byte) ([]byte, error) {
// Implementation for batch verification
// Returns bitmap of valid signatures
return nil, errors.New("batch verify not yet implemented")
}
Rationale
LSAG Choice
We chose LSAG (Linkable Spontaneous Anonymous Group) because:
- Linkability: Key images enable double-spend detection
- Spontaneous: No group manager or setup required
- Efficiency: O(n) signature size and verification time
- Proven Security: Well-studied cryptographic construction
Ring Size Trade-offs
| Ring Size | Anonymity | Signature Size | Verification Time |
|---|---|---|---|
| 2 | 50% | 161 bytes | ~5ms |
| 8 | 87.5% | 545 bytes | ~20ms |
| 16 | 93.75% | 1057 bytes | ~40ms |
| 64 | 98.4% | 4129 bytes | ~160ms |
Key Image Design
The key image I = x * H(P) ensures:
- Same private key → same key image (linkability)
- Different from public key (privacy)
- Cannot derive private key from key image
Backwards Compatibility
This is a new precompile with no backwards compatibility concerns.
The signature format is compatible with:
- Monero ring signatures (adapted)
- CryptoNote protocol signatures
Test Cases
Test Vector 1: 4-Member Ring
Ring Public Keys (compressed):
P[0] = 0x02...a1 (33 bytes)
P[1] = 0x03...b2 (33 bytes) <- signer
P[2] = 0x02...c3 (33 bytes)
P[3] = 0x03...d4 (33 bytes)
Signer Private Key: 0x...sk (32 bytes)
Signer Index: 1
Message: 0x5472616e73666572203130204c5558 (Transfer 10 LUX)
Expected Key Image: 0x02...ki (33 bytes)
Signature: keyImage || c[4] || s[4]
Solidity Test
function testRingSignature() public {
bytes[] memory ring = new bytes[](4);
ring[0] = hex"02...";
ring[1] = hex"03...";
ring[2] = hex"02...";
ring[3] = hex"03...";
bytes memory signerSk = hex"...";
bytes memory message = "Transfer 10 LUX";
// Sign
(bytes memory sig, bytes memory keyImage) = RingSignature.signLSAG(
ring,
signerSk,
1, // signer index
message
);
// Verify
(bool valid, bytes memory extractedKI) = RingSignature.verifyLSAG(
ring,
sig,
message
);
assertTrue(valid);
assertEq(keyImage, extractedKI);
}
Reference Implementation
Implementation exists in:
github.com/luxfi/crypto/ring: Go implementation with LSAG and Lattice-LSAGgithub.com/luxfi/coreth/precompile/contracts/ring: EVM precompile
Security Considerations
Anonymity Set
- Minimum Ring Size: At least 2 members, but 8+ recommended
- Decoy Selection: Random selection from blockchain UTXOs
- Ring Reuse: Avoid reusing exact same ring for multiple transactions
Key Image Security
- Double-Spend: Key image registry MUST be checked before accepting
- Uniqueness: Each private key produces exactly one key image
- Non-Reversible: Cannot derive private key from key image
Side Channels
- Constant-Time: All operations are constant-time
- Timing: Ring verification takes same time regardless of signer position
- Memory Access: No secret-dependent memory access patterns
Attack Vectors
| Attack | Mitigation |
|---|---|
| Trace Analysis | Sufficient ring size, random decoy selection |
| Key Image Collision | Collision-resistant hash function |
| Ring Intersection | Varied decoy selection per transaction |
| Timing Attack | Constant-time implementation |
Post-Quantum Security
The Lattice-LSAG scheme (ID 0x10) provides post-quantum security using Module-LWE hardness. It should be used when quantum resistance is required.
Copyright
Copyright and related rights waived via CC0.