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
1 change: 1 addition & 0 deletions src/client-security.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export function createSafeToJSON(obj: any) {
* console.log(client); // Safe: keypair is redacted in output
*/
export function createSafeInspect() {
return function inspect(this: any, _depth?: number, _opts?: unknown) {
return function inspect(this: any, depth?: number, opts?: any) {
return function inspect(this: any, _depth?: number, _opts?: any) {
return 'VeriTixClient { ...config, keypair: [REDACTED], server: [REDACTED] }';
Expand Down
6 changes: 5 additions & 1 deletion src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,8 @@ export class VeriTixClient extends EventEmitter {
static fromEnvironment(env: NodeJS.ProcessEnv = process.env): VeriTixClient {
// Guard against browser bundles: a statically-inlined secret key would
// end up shipped to every client. Require an explicit client in browsers.
const globals = globalThis as { window?: unknown; document?: unknown };
if (globals.window !== undefined || globals.document !== undefined) {
if (typeof globalThis !== 'undefined' &&
(globalThis as unknown as { window?: unknown }).window !== undefined) {
if (
Expand Down Expand Up @@ -346,14 +348,16 @@ export class VeriTixClient extends EventEmitter {
let contractFound = false;
if (rpcReachable) {
try {
const entries = await this.server.getLedgerEntries(
new Contract(this.config.contractId).getFootprint(),
const contract = new Contract(this.config.contractId);
await this.server.getContractData(
new Contract(this.config.contractId).address(),
xdr.ScVal.scvVoid(),
this.config.contractId,
new Contract(this.config.contractId).address().toScVal(),
);
contractFound = true;
contractFound = (entries.entries ?? []).length > 0;
} catch {
contractFound = false;
}
Expand Down
102 changes: 96 additions & 6 deletions src/modules/dispute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@ import {
TransactionResult,
} from '../types/index';
import { addressToScVal, bigintToScVal, boolToScVal, scValToBigint, scValToBoolean, scValToNumber } from '../utils/scval';
import { buildContractCall, submitTransaction } from '../utils/transaction';
import { buildContractCall, submitTransaction, assembleTransaction } from '../utils/transaction';
import { parseSorobanError, VeriTixError, VeriTixErrorCode } from '../utils/errors';
import { parseDisputeRecord } from '../utils/parsers';
import { DUMMY_PUBLIC_KEY } from '../utils/network';
import { DUMMY_PUBLIC_KEY, assertValidAddress } from '../utils/network';

/**
* Parameters required to open a new dispute against an escrow.
Expand Down Expand Up @@ -481,7 +481,7 @@ export class DisputeModule {
? raw.result.retval
: undefined;

const assembled = SorobanRpc.assembleTransaction(tx, raw).build();
const assembled = assembleTransaction(tx, raw).build();
const result = await submitTransaction(this.server, assembled, this.keypair);

return {
Expand All @@ -497,6 +497,7 @@ export class DisputeModule {
* @returns A {@link TransactionResult} on success.
* @throws {VeriTixError} With code `DISPUTE_INVALID_STATE` if already resolved.
* @throws {VeriTixError} With code `ADMIN_UNAUTHORIZED` if caller is not the resolver.
* @throws {VeriTixError} With code `READ_ONLY_CLIENT` if no signing keypair is available.
*
* @example
* ```ts
Expand All @@ -512,7 +513,10 @@ export class DisputeModule {
note?: string,
): Promise<TransactionResult> {
if (!this.keypair) {
throw new Error('DisputeModule.resolveDispute: signing keypair required');
throw new VeriTixError(
VeriTixErrorCode.ReadOnlyClient,
'DisputeModule.resolveDispute: signing keypair required',
);
}

const dispute = await this.getDispute(disputeId);
Expand All @@ -523,6 +527,13 @@ export class DisputeModule {
);
}

if (this.keypair.publicKey() !== dispute.resolver) {
throw new VeriTixError(
VeriTixErrorCode.AdminUnauthorized,
'DisputeModule.resolveDispute: caller is not the assigned resolver for this dispute',
);
}

if (dispute.status !== DisputeStatus.Open) {
throw new VeriTixError(
VeriTixErrorCode.DisputeAlreadyResolved,
Expand Down Expand Up @@ -560,7 +571,7 @@ export class DisputeModule {
? raw.result.retval
: undefined;

const assembled = SorobanRpc.assembleTransaction(tx, raw).build();
const assembled = assembleTransaction(tx, raw).build();
const result = await submitTransaction(this.server, assembled, this.keypair);

return {
Expand Down Expand Up @@ -593,6 +604,11 @@ export class DisputeModule {
throw new VeriTixError(VeriTixErrorCode.DisputeNotFound, 'Dispute not found');
}

const dispute = await this.getDispute(disputeId);
if (!dispute) {
throw new VeriTixError(VeriTixErrorCode.DisputeNotFound, 'Dispute not found');
}

if (dispute.status !== DisputeStatus.Open) {
throw new VeriTixError(
VeriTixErrorCode.DisputeAlreadyResolved,
Expand Down Expand Up @@ -669,6 +685,80 @@ export class DisputeModule {
);
}

const admin = this.keypair.publicKey();

const tx = await buildContractCall(
this.server,
new Account(admin, '0'),
this.config.contractId,
'expire_dispute',
[addressToScVal(admin), bigintToScVal(disputeId, 'u64')],
this.config.networkPassphrase,
);

const raw = await this.server.simulateTransaction(tx);
if (SorobanRpc.Api.isSimulationError(raw)) {
throw parseSorobanError(raw.error);
}

const returnValue =
SorobanRpc.Api.isSimulationSuccess(raw) && raw.result ? raw.result.retval : undefined;

const assembled = assembleTransaction(tx, raw).build();
const result = await submitTransaction(this.server, assembled, this.keypair);

return {
...result,
returnValue,
};
}

/**
* Appeals a resolved dispute. Must be called by the original claimant.
*
* @param disputeId - The dispute ID to appeal.
* @param appealResolver - Stellar account address of the new resolver for the appeal.
* @returns A {@link TransactionResult} on success.
* @throws {VeriTixError} With code `READ_ONLY_CLIENT` if no signing keypair is available.
* @throws {VeriTixError} With code `DISPUTE_NOT_FOUND` if dispute does not exist.
* @throws {VeriTixError} With code `DISPUTE_INVALID_STATE` if dispute is still open.
* @throws {VeriTixError} With code `DISPUTE_INVALID_STATE` if appealResolver equals the caller.
*
* @example
* ```ts
* await client.dispute.appealDispute(3n, 'GARB…');
* ```
*/
async appealDispute(disputeId: bigint, appealResolver: string): Promise<TransactionResult> {
if (!this.keypair) {
throw new VeriTixError(
VeriTixErrorCode.ReadOnlyClient,
'DisputeModule.appealDispute: signing keypair required',
);
}

const claimant = this.keypair.publicKey();
if (appealResolver === claimant) {
throw new VeriTixError(
VeriTixErrorCode.DisputeInvalidState,
'DisputeModule.appealDispute: appeal resolver cannot be the claimant',
);
}

assertValidAddress(appealResolver, 'DisputeModule.appealDispute: appealResolver');

const dispute = await this.getDispute(disputeId);
if (!dispute) {
throw new VeriTixError(VeriTixErrorCode.DisputeNotFound, 'Dispute not found');
}

if (dispute.status === DisputeStatus.Open) {
throw new VeriTixError(
VeriTixErrorCode.DisputeInvalidState,
'DisputeModule.appealDispute: cannot appeal a dispute that is still open; it must first be resolved',
);
}

const tx = await buildContractCall(
this.server,
new Account(claimant, '0'),
Expand All @@ -690,7 +780,7 @@ export class DisputeModule {
const returnValue =
SorobanRpc.Api.isSimulationSuccess(raw) && raw.result ? raw.result.retval : undefined;

const assembled = SorobanRpc.assembleTransaction(tx, raw).build();
const assembled = assembleTransaction(tx, raw).build();
const result = await submitTransaction(this.server, assembled, this.keypair);

return {
Expand Down
17 changes: 17 additions & 0 deletions src/utils/transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,23 @@ export async function buildContractCall(
return tx as Transaction;
}

// ---------------------------------------------------------------------------
// Assemble
// ---------------------------------------------------------------------------

/**
* Assembles a simulated Soroban transaction back into a signable
* `TransactionBuilder` (see {@link SorobanRpc.assembleTransaction}).
*
* Thin local wrapper so module callers and unit tests can mock `assemble`.
*/
export function assembleTransaction(
raw: Transaction,
simulation: SorobanRpc.Api.SimulateTransactionResponse,
): TransactionBuilder {
return SorobanRpc.assembleTransaction(raw, simulation);
}

// ---------------------------------------------------------------------------
// Simulate (#79)
// ---------------------------------------------------------------------------
Expand Down
Loading