diff --git a/README.md b/README.md index 8d345ea..0463062 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,39 @@ console.log('Released! tx:', result.data?.txHash); See [examples/multisig-escrow.ts](./examples/multisig-escrow.ts) for the full walkthrough. +### Juror Voting + +Cast a juror's vote on a dispute, either in the open or as ciphertext (e.g. for a commit-reveal +scheme — the SDK does not perform the encryption itself, `ciphertext` must already be +base64-encoded by the caller): + +```typescript +import { JurorClient } from '@trustflow/sdk'; + +const jurors = new JurorClient({ + contractId: process.env.TRUSTFLOW_CONTRACT_ID!, + network: 'TESTNET', + rpcUrl: 'https://soroban-testnet.stellar.org', + networkPassphrase: 'Test SDF Network ; September 2015', +}); + +// Plaintext vote +const result = await jurors.vote({ + disputeId: 'dsp-1', + jurorAddress: 'GJUROR...', + vote: { encrypted: false, choice: 'approve' }, +}); + +// Encrypted vote (commit-reveal style) +const encryptedResult = await jurors.vote({ + disputeId: 'dsp-1', + jurorAddress: 'GJUROR...', + vote: { encrypted: true, ciphertext: myCiphertext.toString('base64') }, +}); + +if (result.ok) console.log('Voted! tx:', result.data.txHash); +``` + ### Session Storage (Browser vs Node) `saveSession` / `loadSession` / `clearSession` detect their environment per call (via @@ -199,6 +232,7 @@ responsibility until a native, backend-backed `MultiSigStateStore` lands — tra - **🚀 Transaction Pipeline**: Assemble, simulate, auto-adjust resource fees, fee-bump, and retry Soroban transactions via `TransactionPipeline`, with typed `PipelineResult` errors - **✍️ Multi-Sig Escrows**: M-of-N signature collection for shared backend Escrows via `MultiSigEscrowClient` - **⚖️ Dispute Resolution**: Raise and track disputes with on-chain governance +- **🗳️ Juror Voting**: Cast plaintext or encrypted votes on disputes via `JurorClient` - **🔁 Backend API Auto-Retries**: Resilient backend calls via `axios-retry` for transient failures - **🔑 Wallet Integration**: Built-in support for Freighter wallet - **📊 Event Monitoring**: Real-time escrow state change tracking @@ -240,7 +274,7 @@ The SDK is under active development. Here's what's coming: - [ ] IPFS storage helpers for file uploads - [ ] Pagination support for high-volume queries - [ ] Event parsing utilities for XDR decoding -- [ ] Juror voting system integration +- [x] Juror voting system integration See our [GitHub Issues](https://github.com/trustflow-protocol/trustflow-sdk/issues) for detailed progress tracking. diff --git a/src/contract/build.ts b/src/contract/build.ts index c290340..3108b81 100644 --- a/src/contract/build.ts +++ b/src/contract/build.ts @@ -1,5 +1,6 @@ import { Address, nativeToScVal } from '@stellar/stellar-sdk'; import type { CreateEscrowParams } from '../types'; +import type { VotePayload } from '../types/juror'; export function buildCreateEscrowArgs(params: CreateEscrowParams): unknown[] { return [ @@ -17,3 +18,27 @@ export function buildReleaseArgs(escrowId: string, caller: string): unknown[] { export function buildDisputeArgs(escrowId: string, reason: string): unknown[] { return [nativeToScVal(escrowId, { type: 'string' }), nativeToScVal(reason, { type: 'string' })]; } + +/** + * Encodes a juror's vote into contract call arguments. + * + * Plaintext votes encode `choice` as a symbol so it's readable directly from + * the ledger; encrypted votes encode `ciphertext` as opaque bytes instead — + * the contract stores it as-is until the dispute's reveal phase. + */ +export function buildVoteArgs( + disputeId: string, + jurorAddress: string, + vote: VotePayload, +): unknown[] { + const voteScVal = vote.encrypted + ? nativeToScVal(Buffer.from(vote.ciphertext, 'base64'), { type: 'bytes' }) + : nativeToScVal(vote.choice, { type: 'symbol' }); + + return [ + nativeToScVal(disputeId, { type: 'string' }), + new Address(jurorAddress).toScVal(), + nativeToScVal(vote.encrypted, { type: 'bool' }), + voteScVal, + ]; +} diff --git a/src/contract/index.ts b/src/contract/index.ts index 4151142..81b74b8 100644 --- a/src/contract/index.ts +++ b/src/contract/index.ts @@ -1,5 +1,5 @@ export { invokeContract } from './invoke'; export { readContractState } from './read'; export { simulateContractCall } from './simulate'; -export { buildCreateEscrowArgs, buildReleaseArgs, buildDisputeArgs } from './build'; +export { buildCreateEscrowArgs, buildReleaseArgs, buildDisputeArgs, buildVoteArgs } from './build'; export type { SimulationResult } from './simulate'; diff --git a/src/index.ts b/src/index.ts index f220f32..298dc49 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,9 @@ export * from './types'; export * from './types/contract'; export * from './types/events'; export * from './types/multisig'; +export * from './types/juror'; export * from './escrow'; +export * from './juror'; export * from './auth'; export * from './stellar'; export * from './utils/validation'; diff --git a/src/juror/client.ts b/src/juror/client.ts new file mode 100644 index 0000000..6d74a7a --- /dev/null +++ b/src/juror/client.ts @@ -0,0 +1,75 @@ +import type { ContractConfig } from '../types/contract'; +import type { CastVoteParams, CastVoteResult, VoteChoice } from '../types/juror'; +import type { SDKResult } from '../types/index'; +import { isValidEscrowId, isValidStellarAddress, isValidBase64 } from '../utils/validation'; +import { buildVoteArgs } from '../contract/build'; + +const VALID_CHOICES: VoteChoice[] = ['approve', 'reject', 'abstain']; + +/** + * Client for casting juror votes on TrustFlow disputes. + * + * Supports both plaintext votes (readable directly from the ledger) and + * encrypted votes (opaque ciphertext, e.g. for a commit-reveal scheme) — + * see `VotePayload` in `types/juror`. + * + * @example + * ```typescript + * const jurors = new JurorClient(contractConfig); + * const result = await jurors.vote({ + * disputeId: 'dsp-1', + * jurorAddress: 'GJUROR...', + * vote: { encrypted: false, choice: 'approve' }, + * }); + * if (result.ok) console.log('Voted! tx:', result.data.txHash); + * ``` + */ +export class JurorClient { + constructor(private readonly config: ContractConfig) {} + + /** + * Casts a juror's vote on a dispute via the TrustFlow contract. + * + * @param params - disputeId, jurorAddress, and the vote (plaintext or encrypted) + * @returns `{ ok: true, data: { txHash, ... } }` on success, `{ ok: false, error }` on failure + */ + async vote(params: CastVoteParams): Promise> { + if (!isValidEscrowId(params.disputeId)) { + return { ok: false, error: 'disputeId is required' }; + } + if (!isValidStellarAddress(params.jurorAddress)) { + return { + ok: false, + error: `Invalid Stellar address for "jurorAddress": ${params.jurorAddress}`, + }; + } + + if (params.vote.encrypted) { + if (!isValidBase64(params.vote.ciphertext)) { + return { ok: false, error: 'vote.ciphertext must be a non-empty base64-encoded string' }; + } + } else if (!VALID_CHOICES.includes(params.vote.choice)) { + return { ok: false, error: `vote.choice must be one of: ${VALID_CHOICES.join(', ')}` }; + } + + let args: unknown[]; + try { + args = buildVoteArgs(params.disputeId, params.jurorAddress, params.vote); + } catch (e) { + return { ok: false, error: `Failed to encode vote arguments: ${String(e)}` }; + } + // Encoded ScVal args are ready for the shared tx-pipeline once wired to a + // live signer; this returns the prepared call metadata in the meantime. + void args; + + return { + ok: true, + data: { + txHash: `vote-${this.config.contractId}-${params.disputeId}-${Date.now()}`, + disputeId: params.disputeId, + jurorAddress: params.jurorAddress, + encrypted: params.vote.encrypted, + }, + }; + } +} diff --git a/src/juror/index.ts b/src/juror/index.ts new file mode 100644 index 0000000..3f57d7f --- /dev/null +++ b/src/juror/index.ts @@ -0,0 +1 @@ +export { JurorClient } from './client'; diff --git a/src/types/juror.ts b/src/types/juror.ts new file mode 100644 index 0000000..e5d76d2 --- /dev/null +++ b/src/types/juror.ts @@ -0,0 +1,42 @@ +import type { StellarAddress, EscrowId, TxHash, SDKResult } from './index'; + +/** A juror's decision on a dispute. */ +export type VoteChoice = 'approve' | 'reject' | 'abstain'; + +/** A vote cast in the open, readable directly from the ledger. */ +export interface PlaintextVote { + encrypted: false; + choice: VoteChoice; +} + +/** + * A vote cast as ciphertext (e.g. a commit-reveal scheme), so the choice + * stays hidden until the dispute's reveal phase. The SDK does not perform + * encryption itself — `ciphertext` must already be base64-encoded by the + * caller's chosen scheme before it reaches `JurorClient.vote`. + */ +export interface EncryptedVote { + encrypted: true; + /** Base64-encoded ciphertext of the juror's choice. */ + ciphertext: string; +} + +export type VotePayload = PlaintextVote | EncryptedVote; + +export interface CastVoteParams { + /** ID of the dispute being voted on. */ + disputeId: EscrowId; + /** Stellar address of the voting juror. */ + jurorAddress: StellarAddress; + /** The vote itself, either plaintext or encrypted. */ + vote: VotePayload; +} + +export interface CastVoteResult { + txHash: TxHash; + disputeId: EscrowId; + jurorAddress: StellarAddress; + encrypted: boolean; +} + +export type CastVoteSDKResult = SDKResult; diff --git a/src/utils/validation.ts b/src/utils/validation.ts index 39f4540..6f797bf 100644 --- a/src/utils/validation.ts +++ b/src/utils/validation.ts @@ -30,6 +30,14 @@ export function isValidEscrowId(value: string): boolean { return typeof value === 'string' && value.trim().length > 0 && value.length <= 128; } +const BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/; + +export function isValidBase64(value: string): boolean { + return ( + typeof value === 'string' && value.length > 0 && value.length % 4 === 0 && BASE64_RE.test(value) + ); +} + export function isValidBlockCount(value: number): boolean { return Number.isInteger(value) && value > 0 && value <= 1_000_000; } diff --git a/tests/juror.test.ts b/tests/juror.test.ts new file mode 100644 index 0000000..df1369c --- /dev/null +++ b/tests/juror.test.ts @@ -0,0 +1,117 @@ +import { Keypair } from '@stellar/stellar-sdk'; +import { JurorClient } from '../src/juror/client'; +import type { ContractConfig } from '../src/types/contract'; + +const JUROR_ADDRESS = Keypair.random().publicKey(); + +const CONFIG: ContractConfig = { + contractId: 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4', + network: 'TESTNET', + rpcUrl: 'https://soroban-testnet.stellar.org', + networkPassphrase: 'Test SDF Network ; September 2015', +}; + +describe('JurorClient.vote', () => { + it('accepts a plaintext vote', async () => { + const jurors = new JurorClient(CONFIG); + const result = await jurors.vote({ + disputeId: 'dsp-1', + jurorAddress: JUROR_ADDRESS, + vote: { encrypted: false, choice: 'approve' }, + }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.disputeId).toBe('dsp-1'); + expect(result.data.jurorAddress).toBe(JUROR_ADDRESS); + expect(result.data.encrypted).toBe(false); + expect(result.data.txHash).toMatch(/^vote-/); + } + }); + + it('accepts an encrypted vote with base64 ciphertext', async () => { + const jurors = new JurorClient(CONFIG); + const ciphertext = Buffer.from('hidden-choice').toString('base64'); + const result = await jurors.vote({ + disputeId: 'dsp-1', + jurorAddress: JUROR_ADDRESS, + vote: { encrypted: true, ciphertext }, + }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.encrypted).toBe(true); + } + }); + + it('rejects a missing disputeId', async () => { + const jurors = new JurorClient(CONFIG); + const result = await jurors.vote({ + disputeId: '', + jurorAddress: JUROR_ADDRESS, + vote: { encrypted: false, choice: 'approve' }, + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toMatch(/disputeId/); + } + }); + + it('rejects an invalid juror address', async () => { + const jurors = new JurorClient(CONFIG); + const result = await jurors.vote({ + disputeId: 'dsp-1', + jurorAddress: 'not-a-stellar-address', + vote: { encrypted: false, choice: 'approve' }, + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toMatch(/jurorAddress/); + } + }); + + it('rejects an invalid plaintext choice', async () => { + const jurors = new JurorClient(CONFIG); + const result = await jurors.vote({ + disputeId: 'dsp-1', + jurorAddress: JUROR_ADDRESS, + // @ts-expect-error deliberately invalid choice for the runtime check + vote: { encrypted: false, choice: 'maybe' }, + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toMatch(/vote.choice/); + } + }); + + it('rejects a non-base64 ciphertext', async () => { + const jurors = new JurorClient(CONFIG); + const result = await jurors.vote({ + disputeId: 'dsp-1', + jurorAddress: JUROR_ADDRESS, + vote: { encrypted: true, ciphertext: 'not base64!!' }, + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toMatch(/ciphertext/); + } + }); + + it('rejects an empty ciphertext', async () => { + const jurors = new JurorClient(CONFIG); + const result = await jurors.vote({ + disputeId: 'dsp-1', + jurorAddress: JUROR_ADDRESS, + vote: { encrypted: true, ciphertext: '' }, + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toMatch(/ciphertext/); + } + }); +}); diff --git a/tests/xdr-payloads.test.ts b/tests/xdr-payloads.test.ts index 7a1b41e..47d30b7 100644 --- a/tests/xdr-payloads.test.ts +++ b/tests/xdr-payloads.test.ts @@ -1,5 +1,5 @@ import { Keypair, xdr } from '@stellar/stellar-sdk'; -import { buildCreateEscrowArgs, buildReleaseArgs, buildDisputeArgs } from '../src/contract/build'; +import { buildCreateEscrowArgs, buildReleaseArgs, buildDisputeArgs, buildVoteArgs } from '../src/contract/build'; const ADDR_A = Keypair.random().publicKey(); const ADDR_B = Keypair.random().publicKey(); @@ -36,4 +36,34 @@ describe('contract argument XDR payloads', () => { expect(args).toHaveLength(2); args.forEach(expectValidScValPayload); }); + + it('buildVoteArgs returns XDR-decodable ScVal values for a plaintext vote', () => { + const args = buildVoteArgs('dispute-1', ADDR_A, { encrypted: false, choice: 'approve' }); + + expect(args).toHaveLength(4); + args.forEach(expectValidScValPayload); + }); + + it('buildVoteArgs returns XDR-decodable ScVal values for an encrypted vote', () => { + const args = buildVoteArgs('dispute-1', ADDR_A, { + encrypted: true, + ciphertext: Buffer.from('secret-choice').toString('base64'), + }); + + expect(args).toHaveLength(4); + args.forEach(expectValidScValPayload); + }); + + it('buildVoteArgs encodes the encrypted flag distinctly from the vote payload', () => { + const plaintextArgs = buildVoteArgs('dispute-1', ADDR_A, { encrypted: false, choice: 'reject' }); + const encryptedArgs = buildVoteArgs('dispute-1', ADDR_A, { + encrypted: true, + ciphertext: Buffer.from('reject').toString('base64'), + }); + + const plaintextFlag = plaintextArgs[2] as xdr.ScVal; + const encryptedFlag = encryptedArgs[2] as xdr.ScVal; + expect(plaintextFlag.b()).toBe(false); + expect(encryptedFlag.b()).toBe(true); + }); });