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
36 changes: 35 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<T>` 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
Expand Down Expand Up @@ -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.

Expand Down
25 changes: 25 additions & 0 deletions src/contract/build.ts
Original file line number Diff line number Diff line change
@@ -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 [
Expand All @@ -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,
];
}
2 changes: 1 addition & 1 deletion src/contract/index.ts
Original file line number Diff line number Diff line change
@@ -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';
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
75 changes: 75 additions & 0 deletions src/juror/client.ts
Original file line number Diff line number Diff line change
@@ -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<SDKResult<CastVoteResult>> {
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,
},
};
}
}
1 change: 1 addition & 0 deletions src/juror/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { JurorClient } from './client';
42 changes: 42 additions & 0 deletions src/types/juror.ts
Original file line number Diff line number Diff line change
@@ -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<CastVoteResult>;
8 changes: 8 additions & 0 deletions src/utils/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
117 changes: 117 additions & 0 deletions tests/juror.test.ts
Original file line number Diff line number Diff line change
@@ -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/);
}
});
});
Loading