Skip to content
Merged
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
12 changes: 6 additions & 6 deletions contracts/round/src/error_paths.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ use crate::types::{ClearingRule, DataKey, Error, Status};

use super::{
assert_try_create_round_err, assert_try_contract_err, b32, commit_bid, commitment,
drand_round, funded_bidder, open_round, real_sig, sac_asset_config, setup, setup_drand,
Fixture, GENESIS, PERIOD, VEC_ROUND,
drand_round, funded_bidder, native_xlm, open_round, real_sig, setup, setup_drand, Fixture,
GENESIS, PERIOD, VEC_ROUND,
};

const MAX_BIDDERS: u32 = 500;
Expand Down Expand Up @@ -218,7 +218,7 @@ fn error_path_commit_deadline_after_reveal() {
&2_000,
&2_500,
&Bytes::from_array(&f.env, b"a"),
&sac_asset_config(&f.env),
&native_xlm(&f.env),
),
Error::CommitDeadlineAfterReveal,
);
Expand Down Expand Up @@ -374,7 +374,7 @@ fn error_path_invalid_drand_signature() {
&commit_deadline,
&reveal_deadline,
&Bytes::from_array(&f.env, b"auditor"),
&sac_asset_config(&f.env),
&native_xlm(&f.env),
);
let bidder = funded_bidder(&f, 1_000);
commit_bid(&f, id, &bidder, 100, 100, 0x01);
Expand Down Expand Up @@ -429,7 +429,7 @@ fn error_path_payload_too_large() {
&1_500,
&2_500,
&oversized_bytes(&f.env, 1025),
&sac_asset_config(&f.env),
&native_xlm(&f.env),
),
Error::PayloadTooLarge,
);
Expand Down Expand Up @@ -492,7 +492,7 @@ fn error_path_deadline_in_past() {
&500,
&2_500,
&Bytes::from_array(&f.env, b"a"),
&sac_asset_config(&f.env),
&native_xlm(&f.env),
),
Error::DeadlineInPast,
);
Expand Down
33 changes: 14 additions & 19 deletions contracts/round/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

use soroban_sdk::{
testutils::{Address as _, Ledger},
token, Address, Bytes, BytesN, ConversionError, Env, InvokeError, Vec,
token, Address, Bytes, BytesN, ConversionError, Env, InvokeError, String, Vec,
};
use soroban_sdk::testutils::storage::Temporary as TemporaryStorageTest;

Expand Down Expand Up @@ -140,7 +140,7 @@ fn drand_round(f: &Fixture, operator: &Address, commit_deadline: u64, reveal_dea
&commit_deadline,
&reveal_deadline,
&Bytes::from_array(&f.env, b"auditor"),
&sac_asset_config(&f.env),
&native_xlm(&f.env),
)
}

Expand All @@ -160,19 +160,16 @@ fn b32(env: &Env, byte: u8) -> BytesN<32> {
BytesN::from_array(env, &[byte; 32])
}

fn sac_asset_config(env: &Env) -> RoundAssetConfig {
/// Native XLM asset config, the default for tests that don't exercise SAC
/// binding. `create_round` takes it on every call but does not itself validate
/// the fields, so tests that only need a well-formed round pass this.
pub fn native_xlm(env: &Env) -> RoundAssetConfig {
RoundAssetConfig {
asset_type: soroban_sdk::String::from_str(env, "sac"),
contract_id: soroban_sdk::String::from_str(
env,
"CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4",
),
code: soroban_sdk::String::from_str(env, "USDC"),
asset_type: String::from_str(env, "native"),
contract_id: String::from_str(env, ""),
code: String::from_str(env, "XLM"),
decimals: 7,
issuer: soroban_sdk::String::from_str(
env,
"GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF",
),
issuer: String::from_str(env, ""),
}
}

Expand All @@ -186,7 +183,7 @@ fn open_round(f: &Fixture, operator: &Address) -> u64 {
&1_500,
&2_500,
&Bytes::from_array(&f.env, b"auditor-pubkey"),
&asset_config,
&native_xlm(&f.env),
)
}

Expand Down Expand Up @@ -313,8 +310,7 @@ fn create_round_rejects_commit_after_reveal() {
let operator = Address::generate(&f.env);
let res = f.client.try_create_round(
&operator, &b32(&f.env, 1), &2_000, &ClearingRule::HighestBid,
&2_000, &2_500, &Bytes::from_array(&f.env, b"a"),
&sac_asset_config(&f.env),
&2_000, &2_500, &Bytes::from_array(&f.env, b"a"), &native_xlm(&f.env),
);
assert!(res.is_err());
}
Expand All @@ -325,8 +321,7 @@ fn create_round_rejects_deadline_in_past() {
let operator = Address::generate(&f.env);
let res = f.client.try_create_round(
&operator, &b32(&f.env, 1), &2_000, &ClearingRule::HighestBid,
&500, &2_500, &Bytes::from_array(&f.env, b"a"),
&sac_asset_config(&f.env),
&500, &2_500, &Bytes::from_array(&f.env, b"a"), &native_xlm(&f.env),
);
assert!(res.is_err());
}
Expand Down Expand Up @@ -1107,7 +1102,7 @@ fn full_lifecycle_real_drand_signature() {
let id = f.client.create_round(
&operator, &b32(&f.env, 0xAB), &VEC_ROUND, &ClearingRule::HighestBid,
&commit_deadline, &reveal_deadline, &Bytes::from_array(&f.env, b"auditor"),
&sac_asset_config(&f.env),
&native_xlm(&f.env),
);

let alice = funded_bidder(&f, 1_000);
Expand Down
13 changes: 8 additions & 5 deletions packages/sdk/src/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {
SubmitSignedTransactionParams,
TransactionSubmitter,
} from "./submitter.js";
import { sealFixture, fixtureBinding } from "./testing/seal-fixture.js";

const BASE_CONFIG = {
rpcUrl: "https://example.com",
Expand Down Expand Up @@ -237,15 +238,17 @@ describe("SubRosaClient source configuration", () => {
it("rejects commit without a bidder source using a typed error", async () => {
const client = new SubRosaClient(BASE_CONFIG);

// A real seal: commit() runs the sealed-bid gate before it resolves the
// bidder source, so a placeholder blob would fail on its own terms and
// this test would stop being about the source check.
const sealed = await sealFixture();

await assert.rejects(
client.commit({
roundId: 1,
sealed: {
commitment: new Uint8Array(32),
ciphertext: new Uint8Array([0x61, 0x67, 0x65]), // non-empty
auditorBlob: new Uint8Array(1), // non-empty
},
sealed,
escrow: 1n,
binding: fixtureBinding(),
}),
(error: unknown) => {
assert.ok(error instanceof SubRosaClientConfigError);
Expand Down
95 changes: 56 additions & 39 deletions packages/sdk/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,9 @@ import {
} from "@sub-rosa/round-bindings/event-snapshot";
import { toHex } from "@sub-rosa/tlock";
import type { SealedBid } from "@sub-rosa/tlock";
import type { RoundReceipt, RoundReceiptEvent } from "./receipt.js";
import { validateEncryptedBlob } from "./encrypted-blob.js";
import type { RoundReceipt } from "./receipt.js";
import { assertSealedBid } from "./encrypted-blob.js";
import type { SealedBidBinding } from "./encrypted-blob.js";
import { networkFingerprint } from "./receipt.js";
import type { TransactionSubmitter } from "./submitter.js";
import {
Expand All @@ -39,6 +40,7 @@ import {
type PreflightResult,
} from "./preflight.js";
import {
SubRosaAssetValidationError,
SubRosaClientConfigError,
SubRosaPaginationError,
SubRosaMissingReturnValueError,
Expand Down Expand Up @@ -107,17 +109,30 @@ export interface SubRosaClientConfig {
* If not provided, no asset validation is performed.
*/
assetConfig?: import("./asset-config.js").AssetConfig;

/**
* @internal Testing hook: inject a mock Soroban RPC server for simulation.
*/
_server?: rpc.Server;
/** Optional passkey session to bind commits and client operations to. */
session?: PasskeySessionBinding;
}

export type ClearingRuleTag = ClearingRule["tag"];

/**
* The contract's `RoundAssetConfig` (contracts/round/src/types.rs).
*
* Declared here rather than imported because the generated bindings in this
* tree predate the `asset_config` argument on `create_round`; the shape mirrors
* the Rust struct exactly. Once the bindings are regenerated this type and the
* accompanying cast at the call site can both be dropped.
*/
interface RoundAssetConfig {
asset_type: string;
contract_id: string;
code: string;
decimals: number;
issuer: string;
}

export interface CreateRoundParams {
/** sha256 (or any opaque 32-byte ref) of the off-chain item description. */
itemRef: Uint8Array;
Expand Down Expand Up @@ -145,8 +160,16 @@ export interface CommitParams {
escrow: bigint;
/** Bidder address. Default: the configured signer's public key. */
bidder?: string;
/** Optional passkey session to bind this commit to. */
session?: PasskeySessionBinding;
/**
* The value, nonce, and Drand round the seal was produced from.
*
* Supplying it makes `commit` verify the seal against them before submitting
* — same acceptance rule the sealer works to, so a blob that decodes but
* commits to the wrong value is rejected here instead of becoming an
* on-chain commitment the contract can never open. The value is never logged
* or included in the resulting error.
*/
binding?: SealedBidBinding;
}

export interface RevealParams {
Expand Down Expand Up @@ -424,8 +447,25 @@ export class SubRosaClient {
const clearing_rule = {
tag: params.clearingRule ?? "HighestBid",
values: undefined,
} as ClearingRule;
const assetConfig = this.#buildAssetConfig(params);
} as ClearingRule;
// Build asset config for the round
let assetConfig: RoundAssetConfig = {
asset_type: "native",
contract_id: "",
code: "XLM",
decimals: 7,
issuer: "",
};
if (params.assetConfig) {
const { type, code, contractId, issuer, decimals } = params.assetConfig;
assetConfig = {
asset_type: type,
contract_id: contractId || "",
code: code || "XLM",
decimals: decimals ?? 7,
issuer: issuer || "",
};
}

const tx = await this.#validatedContractCall(() =>
this.contract.create_round({
Expand All @@ -437,42 +477,19 @@ export class SubRosaClient {
reveal_deadline: toBigInt(params.revealDeadline),
auditor_pubkey: toBuffer(params.auditorPubkey),
asset_config: assetConfig,
}),
} as Parameters<typeof this.contract.create_round>[0]),
);

return this.#sendUnwrap(tx);
}

async commit(params: CommitParams): Promise<void> {
const session = params.session ?? this.#session;
if (session) {
validatePasskeySession(session, {
contractId: this.contractId,
networkPassphrase: this.networkPassphrase,
account: params.bidder ?? this.#source,
});
}

// Validate encrypted blobs before submitting — catches size/encoding
// issues early, before paying gas for an on-chain revert (PayloadTooLarge).
const ciphertextResult = validateEncryptedBlob(
params.sealed.ciphertext,
"ciphertext",
);
if (!ciphertextResult.valid) {
throw new SubRosaClientConfigError(
ciphertextResult.issues.map((i) => i.message).join("; "),
);
}
const auditorBlobResult = validateEncryptedBlob(
params.sealed.auditorBlob,
"auditor_blob",
);
if (!auditorBlobResult.valid) {
throw new SubRosaClientConfigError(
auditorBlobResult.issues.map((i) => i.message).join("; "),
);
}
// Gate the seal before submitting. Size/encoding defects surface here
// instead of as an on-chain PayloadTooLarge revert, and — when the caller
// passes the value/nonce/round it sealed from — a blob whose commitment
// does not match never reaches the chain, where it would be committed and
// never open.
assertSealedBid(params.sealed, params.binding);

// Validate asset config matches the round's expected asset
if (this.#assetConfig) {
Expand Down
Loading
Loading