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
23 changes: 23 additions & 0 deletions apps/api/db/migrations/032_add_shielded_provider_staking.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
-- 032_add_shielded_provider_staking.sql
-- Zero-Knowledge Anonymous Provider Staking & Shielded Reputation Proofs (#427)

CREATE TABLE IF NOT EXISTS shielded_stake_commitments (
commitment_hash VARCHAR(64) PRIMARY KEY,
merkle_leaf_index INT NOT NULL,
staked_amount_stroops BIGINT NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE IF NOT EXISTS shielded_provider_nullifiers (
nullifier_hash VARCHAR(64) PRIMARY KEY,
provider_id VARCHAR(64) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX IF NOT EXISTS idx_shielded_commitments_active
ON shielded_stake_commitments(is_active)
WHERE is_active = TRUE;

CREATE INDEX IF NOT EXISTS idx_shielded_nullifiers_provider
ON shielded_provider_nullifiers(provider_id);
46 changes: 46 additions & 0 deletions apps/api/src/lib/crypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,50 @@ export function generateSecretPair(): { secretHex: string; secretHashHex: string
const secret = randomBytes(32);
const hash = createHash("sha256").update(secret).digest();
return { secretHex: secret.toString("hex"), secretHashHex: hash.toString("hex") };
}

/**
* Generate a shielded stake commitment: H(secret || amount || timestamp).
* The commitment is a Pedersen-like hash that hides the stake amount and
* provider identity while remaining publicly verifiable.
*/
export function generateShieldedCommitment(
secretHex: string,
amountStroops: string,
): { commitmentHash: string; nullifierHash: string } {
const secret = Buffer.from(secretHex, "hex");
const timestamp = Date.now().toString();

const commitmentHash = createHash("sha256")
.update(secret)
.update(amountStroops)
.update(timestamp)
.update("shielded_commitment_v1")
.digest("hex");

const nullifierHash = createHash("sha256")
.update(secret)
.update("shielded_nullifier_v1")
.digest("hex");

return { commitmentHash, nullifierHash };
}

/**
* Verify a shielded commitment by re-deriving the hash from its components.
*/
export function verifyShieldedCommitment(
secretHex: string,
amountStroops: string,
timestamp: string,
expectedCommitment: string,
): boolean {
const secret = Buffer.from(secretHex, "hex");
const derived = createHash("sha256")
.update(secret)
.update(amountStroops)
.update(timestamp)
.update("shielded_commitment_v1")
.digest("hex");
return derived === expectedCommitment;
}
225 changes: 225 additions & 0 deletions apps/api/src/routes/__tests__/shielded-staking.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
import { describe, it, expect, beforeEach } from "vitest";
import Fastify from "fastify";
import {
shieldedStakingRoutes,
shieldedCommitmentStore,
shieldedNullifierStore,
resetMerkleState,
getMerkleRoot,
} from "../shielded-staking.js";

describe("Shielded Staking Routes (Issue #427)", () => {
let app: ReturnType<typeof Fastify>;

beforeEach(async () => {
shieldedCommitmentStore.clear();
shieldedNullifierStore.clear();
resetMerkleState();
app = Fastify();
await app.register(shieldedStakingRoutes, { prefix: "/api/v1" });
await app.ready();
});

it("accepts a valid shielded stake deposit", async () => {
const commitmentHash = "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90";

const res = await app.inject({
method: "POST",
url: "/api/v1/provider/shielded-stake",
payload: {
commitmentHash,
stakedAmountStroops: "500000000",
},
});

expect(res.statusCode).toBe(201);
const body = res.json();
expect(body.commitmentHash).toBe(commitmentHash);
expect(body.merkleRoot).toBeDefined();
expect(body.merkleLeafIndex).toBe(0);
});

it("returns 409 for duplicate commitment", async () => {
const commitmentHash = "b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90a1";

await app.inject({
method: "POST",
url: "/api/v1/provider/shielded-stake",
payload: { commitmentHash, stakedAmountStroops: "200000000" },
});

const res = await app.inject({
method: "POST",
url: "/api/v1/provider/shielded-stake",
payload: { commitmentHash, stakedAmountStroops: "200000000" },
});

expect(res.statusCode).toBe(409);
expect(res.json().code).toBe("COMMITMENT_EXISTS");
});

it("returns 400 for insufficient stake", async () => {
const res = await app.inject({
method: "POST",
url: "/api/v1/provider/shielded-stake",
payload: {
commitmentHash: "c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2",
stakedAmountStroops: "10000000", // 1 USDC — below minimum
},
});

expect(res.statusCode).toBe(400);
expect(res.json().code).toBe("INSUFFICIENT_STAKE");
});

it("verifies ZK proof and records nullifier", async () => {
const commitmentHash = "d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3";
const nullifierHash = "e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4";

// First deposit
await app.inject({
method: "POST",
url: "/api/v1/provider/shielded-stake",
payload: { commitmentHash, stakedAmountStroops: "500000000" },
});

const merkleRoot = getMerkleRoot();

const verifyRes = await app.inject({
method: "POST",
url: "/api/v1/provider/shielded-stake/verify",
payload: {
proof: "valid_zk_proof_hex_data",
merkleRoot,
nullifierHash,
commitmentHash,
providerId: "provider_001",
minStakeStroops: "100000000",
},
});

expect(verifyRes.statusCode).toBe(200);
const body = verifyRes.json();
expect(body.verified).toBe(true);
expect(body.minimumStakeMet).toBe(true);
});

it("returns 409 when nullifier is reused (double-spend prevention)", async () => {
const commitmentHash = "f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5";
const nullifierHash = "0718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f6";

await app.inject({
method: "POST",
url: "/api/v1/provider/shielded-stake",
payload: { commitmentHash, stakedAmountStroops: "500000000" },
});

const merkleRoot = getMerkleRoot();

// First verification — should succeed
const firstRes = await app.inject({
method: "POST",
url: "/api/v1/provider/shielded-stake/verify",
payload: {
proof: "valid_zk_proof_hex_data",
merkleRoot,
nullifierHash,
commitmentHash,
providerId: "provider_001",
minStakeStroops: "100000000",
},
});
expect(firstRes.statusCode).toBe(200);

// Second verification with same nullifier — should fail
const secondRes = await app.inject({
method: "POST",
url: "/api/v1/provider/shielded-stake/verify",
payload: {
proof: "valid_zk_proof_hex_data",
merkleRoot,
nullifierHash,
commitmentHash,
providerId: "provider_002",
minStakeStroops: "100000000",
},
});
expect(secondRes.statusCode).toBe(409);
expect(secondRes.json().code).toBe("NULLIFIER_SPENT");
});

it("returns 422 for invalid proof", async () => {
const commitmentHash = "18293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f607";

await app.inject({
method: "POST",
url: "/api/v1/provider/shielded-stake",
payload: { commitmentHash, stakedAmountStroops: "500000000" },
});

const res = await app.inject({
method: "POST",
url: "/api/v1/provider/shielded-stake/verify",
payload: {
proof: "invalid_proof",
merkleRoot: getMerkleRoot(),
nullifierHash: "293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718",
commitmentHash,
providerId: "provider_001",
minStakeStroops: "100000000",
},
});

expect(res.statusCode).toBe(422);
});

it("returns commitment status via GET", async () => {
const commitmentHash = "3a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f6071829";

await app.inject({
method: "POST",
url: "/api/v1/provider/shielded-stake",
payload: { commitmentHash, stakedAmountStroops: "500000000" },
});

const res = await app.inject({
method: "GET",
url: `/api/v1/provider/shielded-stake/status/${commitmentHash}`,
});

expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.commitmentHash).toBe(commitmentHash);
expect(body.isActive).toBe(true);
expect(body.stakedAmountStroops).toBe("500000000");
});

it("returns current merkle root", async () => {
const res = await app.inject({
method: "GET",
url: "/api/v1/provider/shielded-stake/merkle-root",
});

expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.merkleRoot).toBeDefined();
expect(body.leafCount).toBe(0);
});

it("merkle root updates after deposits", async () => {
const root1 = (await (await app.inject({ method: "GET", url: "/api/v1/provider/shielded-stake/merkle-root" })).json()).merkleRoot;

await app.inject({
method: "POST",
url: "/api/v1/provider/shielded-stake",
payload: {
commitmentHash: "4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a",
stakedAmountStroops: "500000000",
},
});

const root2 = (await (await app.inject({ method: "GET", url: "/api/v1/provider/shielded-stake/merkle-root" })).json()).merkleRoot;

expect(root2).not.toBe(root1);
});
});
Loading
Loading