Skip to content
Closed
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
4 changes: 4 additions & 0 deletions oracledesk-stellar/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ yarn-error.log*
.soroban/
.stellar/

# V2 resolution specs written by scripts/seed-market.sh. The salt inside each
# file is a secret until its market's spec is revealed, so never commit them.
deployments/specs/

# Agent dry-run/local trace output (agents/core/trace.ts's LocalTraceStore)
.traces/

Expand Down
1 change: 1 addition & 0 deletions oracledesk-stellar/Cargo.lock

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

1 change: 1 addition & 0 deletions oracledesk-stellar/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ build:

test: build
cargo test --workspace
cargo test --manifest-path scripts/spec-hash/Cargo.toml
python3 model/fpmm_model.py
cd x402 && npm test
cd agents && npm test
Expand Down
80 changes: 80 additions & 0 deletions oracledesk-stellar/agents/core/attestation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import assert from "node:assert/strict";
import test from "node:test";
import { Keypair } from "@stellar/stellar-sdk";
import {
buildAttestationMessage,
signAttestation,
verifyAttestation,
type AttestationContext,
} from "./attestation.js";

// Shared byte-for-byte with `golden_attestation_message_and_signature` in
// contracts/resolver/src/lib.rs.
const SEED = Uint8Array.from({ length: 32 }, (_, i) => i);
const CTX: AttestationContext = {
networkPassphrase: "Test SDF Network ; September 2015",
resolverId: "CC3KU5TWAATOBXLWIV4ZTXNEM44VIOY3ZTR7KTJTBOOBP7YIXMCDSWDJ",
marketId: 7n,
round: 3,
outcome: "No",
specHash: Uint8Array.from({ length: 32 }, (_, i) => 0xa0 + i),
};
const GOLDEN_MESSAGE =
"6f7261636c656465736b2d6174746573742d7631cee0302d59844d32bdca915c8203dd44b33fbb7edc19051ea37abedf28ecd4720000001200000001b6aa76760026e0dd76457999dda46739543b1bcce3f54d330b9c17ff08bb043900000000000000070000000301a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf";
const GOLDEN_SIGNATURE =
"3f7bfc9882bbe894518d059b50326069523358f3da1d74b7ed90b8b395862367155ae6ac7934b3ae610e21c3dd595b7d99c2ab122037918917c96543c0cde002";
const GOLDEN_PUBKEY = "03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8";

const hex = (bytes: Uint8Array) => Buffer.from(bytes).toString("hex");
const keypair = () => Keypair.fromRawEd25519Seed(Buffer.from(SEED));

test("buildAttestationMessage matches the golden vector", () => {
assert.equal(hex(buildAttestationMessage(CTX)), GOLDEN_MESSAGE);
});

test("signAttestation matches the golden signature (Ed25519 is deterministic)", () => {
const { pubkey, sig } = signAttestation(keypair(), CTX);
assert.equal(hex(pubkey), GOLDEN_PUBKEY);
assert.equal(hex(sig), GOLDEN_SIGNATURE);
});

test("verifyAttestation accepts the golden signature", () => {
const { pubkey, sig } = signAttestation(keypair(), CTX);
assert.equal(verifyAttestation(pubkey, sig, CTX), true);
});

test("verifyAttestation rejects any tampered context field", () => {
const { pubkey, sig } = signAttestation(keypair(), CTX);
const tampered: AttestationContext[] = [
{ ...CTX, marketId: 8n },
{ ...CTX, round: 4 },
{ ...CTX, outcome: "Yes" },
{ ...CTX, networkPassphrase: "Public Global Stellar Network ; September 2015" },
{ ...CTX, resolverId: "CCILJXZCHKAIBTMJ7NSQE756OGLKTOYNSWNNZXWAYZZA4ECAUEC2CNVF" },
{ ...CTX, specHash: Uint8Array.from({ length: 32 }, () => 1) },
];
for (const ctx of tampered) {
assert.equal(verifyAttestation(pubkey, sig, ctx), false);
}
});

test("verifyAttestation rejects a malleated signature (S + group order)", () => {
const { pubkey, sig } = signAttestation(keypair(), CTX);
const order = (1n << 252n) + 27742317777372353535851937790883648493n;
const s = BigInt(`0x${Buffer.from(sig.slice(32)).reverse().toString("hex")}`) + order;
const malleated = Uint8Array.from(sig);
malleated.set(Buffer.from(s.toString(16).padStart(64, "0"), "hex").reverse(), 32);
assert.equal(verifyAttestation(pubkey, malleated, CTX), false);
});

test("verifyAttestation rejects malformed lengths", () => {
const { pubkey, sig } = signAttestation(keypair(), CTX);
assert.equal(verifyAttestation(pubkey.slice(1), sig, CTX), false);
assert.equal(verifyAttestation(pubkey, sig.slice(1), CTX), false);
});

test("buildAttestationMessage validates its inputs", () => {
assert.throws(() => buildAttestationMessage({ ...CTX, specHash: new Uint8Array(31) }));
assert.throws(() => buildAttestationMessage({ ...CTX, round: -1 }));
assert.throws(() => buildAttestationMessage({ ...CTX, round: 2 ** 32 }));
});
100 changes: 100 additions & 0 deletions oracledesk-stellar/agents/core/attestation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/**
* Off-chain Ed25519 attestations for `resolver.submit_signatures`.
*
* A committee member signs the message built here with a plain Ed25519 key;
* anyone (typically the agent or a keeper) collects the signatures and submits
* them in one transaction. The byte layout must match
* `Resolver::attestation_message` in contracts/resolver/src/lib.rs exactly:
*
* "oracledesk-attest-v1" 20 ASCII bytes, domain tag
* || network_id 32 bytes, sha256(network passphrase)
* || ScVal-XDR(resolver contract address) 40 bytes for a C... address
* || market_id u64 big-endian
* || round u32 big-endian
* || outcome 1 byte: 0 = Yes, 1 = No
* || spec_hash 32 bytes, the market's resolution_hash
*
* The message is signed raw (never pre-hashed), matching the contract's
* `ed25519_verify`. The golden vectors in attestation.test.ts are shared with
* the Rust test `golden_attestation_message_and_signature`.
*/

import { createHash } from "node:crypto";
import { Address, Keypair, StrKey } from "@stellar/stellar-sdk";

const DOMAIN = Buffer.from("oracledesk-attest-v1", "ascii");
/** Order of the Ed25519 base-point subgroup. */
const GROUP_ORDER = (1n << 252n) + 27742317777372353535851937790883648493n;

export type AttestedOutcome = "Yes" | "No";

export interface AttestationContext {
networkPassphrase: string;
/** The resolver contract (`C...`) the signatures will be submitted to. */
resolverId: string;
marketId: bigint;
/** `resolver.current_round(marketId)` at signing time. */
round: number;
outcome: AttestedOutcome;
/** 32-byte `resolution_hash` of the market (from market-core `get_market`). */
specHash: Uint8Array;
}

export interface Attestation {
pubkey: Uint8Array;
sig: Uint8Array;
}

export function buildAttestationMessage(ctx: AttestationContext): Uint8Array {
if (ctx.specHash.length !== 32) {
throw new Error(`specHash must be 32 bytes, got ${ctx.specHash.length}`);
}
if (!Number.isInteger(ctx.round) || ctx.round < 0 || ctx.round > 0xffff_ffff) {
throw new Error(`round must be a u32, got ${ctx.round}`);
}
const marketId = Buffer.alloc(8);
marketId.writeBigUInt64BE(ctx.marketId);
const round = Buffer.alloc(4);
round.writeUInt32BE(ctx.round);

return Buffer.concat([
DOMAIN,
createHash("sha256").update(ctx.networkPassphrase).digest(),
Address.fromString(ctx.resolverId).toScVal().toXDR(),
marketId,
round,
Buffer.from([ctx.outcome === "Yes" ? 0 : 1]),
ctx.specHash,
]);
}

export function signAttestation(keypair: Keypair, ctx: AttestationContext): Attestation {
const message = buildAttestationMessage(ctx);
return {
pubkey: new Uint8Array(keypair.rawPublicKey()),
sig: new Uint8Array(keypair.sign(Buffer.from(message))),
};
}

/**
* Pre-filter for a batch before `submit_signatures`: the contract aborts the
* whole batch on one invalid signature, so drop anything this rejects.
*
* Besides the Ed25519 check it rejects a non-canonical `S` (>= the group
* order), which the contract also rejects. It does not re-implement the
* contract's rejection of weak (small-order) public keys and small-order `R`,
* so it can pass a signature the contract refuses; that only makes the batch
* fail, it can never make an invalid signature count.
*/
export function verifyAttestation(
pubkey: Uint8Array,
sig: Uint8Array,
ctx: AttestationContext,
): boolean {
if (pubkey.length !== 32 || sig.length !== 64) return false;
const s = BigInt(`0x${Buffer.from(sig.slice(32)).reverse().toString("hex")}`);
if (s >= GROUP_ORDER) return false;
const message = Buffer.from(buildAttestationMessage(ctx));
const key = Keypair.fromPublicKey(StrKey.encodeEd25519PublicKey(Buffer.from(pubkey)));
return key.verify(message, Buffer.from(sig));
}
1 change: 1 addition & 0 deletions oracledesk-stellar/contracts/resolver/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,5 @@ doctest = false
soroban-sdk = { version = "27.0.6", features = ["experimental_spec_shaking_v2"] }

[dev-dependencies]
ed25519-dalek = "2"
soroban-sdk = { version = "27.0.6", features = ["testutils", "experimental_spec_shaking_v2"] }
Loading
Loading