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
183 changes: 122 additions & 61 deletions sdk/src/contract/contract.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Injectable, Inject, Optional } from '@nestjs/common';
import { Injectable, Inject, Optional } from "@nestjs/common";
import {
TransactionBuilder,
rpc,
Expand All @@ -8,20 +8,31 @@ import {
nativeToScVal,
scValToNative,
BASE_FEE,
} from '@stellar/stellar-sdk';
import { RpcService } from '../network/rpc.service';
import { HorizonService } from '../network/horizon.service';
import { NetworkConfig } from '../network/network.config';
import { WalletAdapter } from '../wallet/wallet.interface';
import { getRaffleContractId } from './constants';
import { ContractFn, ContractFnName } from './bindings';
import { TikkaSdkError, TikkaSdkErrorCode } from '../utils/errors';
import { TransactionLifecycle } from './lifecycle';
import type { TxMemo, PollConfig, SimulateResult, SubmitResult, InvokeLifecycleOptions } from './lifecycle';
export type { TxMemo } from './lifecycle';
export type { SimulateResult, SubmitResult, PollConfig } from './lifecycle';

import { TxResponse } from './response';
} from "@stellar/stellar-sdk";
import { RpcService } from "../network/rpc.service";
import { HorizonService } from "../network/horizon.service";
import { NetworkConfig } from "../network/network.config";
import { WalletAdapter } from "../wallet/wallet.interface";
import { getRaffleContractId } from "./constants";
import { ContractFn, ContractFnName } from "./bindings";
import {
TikkaSdkError,
TikkaSdkErrorCode,
toTypedContractError,
toTypedSdkError,
} from "../utils/errors";
import { TransactionLifecycle } from "./lifecycle";
import type {
TxMemo,
PollConfig,
SimulateResult,
SubmitResult,
InvokeLifecycleOptions,
} from "./lifecycle";
export type { TxMemo } from "./lifecycle";
export type { SimulateResult, SubmitResult, PollConfig } from "./lifecycle";

import { ContractResponse, TxResponse } from "./response";

export interface InvokeOptions {
sourcePublicKey?: string;
Expand Down Expand Up @@ -53,7 +64,6 @@ export interface UnsignedTxResult<T = any> {
networkPassphrase: string;
}


/**
* Detects if an error message indicates a failure in an external contract
* (e.g., a SEP-41 token contract rejecting a transfer).
Expand All @@ -77,11 +87,17 @@ export class ContractService {
constructor(
private readonly rpc: RpcService,
private readonly horizon: HorizonService,
@Inject('NETWORK_CONFIG') private readonly networkConfig: NetworkConfig,
@Optional() @Inject('WALLET_ADAPTER') private wallet?: WalletAdapter,
@Inject("NETWORK_CONFIG") private readonly networkConfig: NetworkConfig,
@Optional() @Inject("WALLET_ADAPTER") private wallet?: WalletAdapter,
) {
this.contractId = getRaffleContractId(networkConfig.network);
this.lifecycle = new TransactionLifecycle(rpc, horizon, networkConfig, wallet, this.contractId);
this.lifecycle = new TransactionLifecycle(
rpc,
horizon,
networkConfig,
wallet,
this.contractId,
);
}

setContractId(id: string): void {
Expand Down Expand Up @@ -118,7 +134,10 @@ export class ContractService {
async simulate<T = unknown>(
method: ContractFnName | string,
params: any[],
options: Pick<InvokeLifecycleOptions, 'sourcePublicKey' | 'fee' | 'memo'> = {},
options: Pick<
InvokeLifecycleOptions,
"sourcePublicKey" | "fee" | "memo"
> = {},
): Promise<SimulateResult<T>> {
return this.lifecycle.simulate<T>(method, params, options);
}
Expand All @@ -127,7 +146,10 @@ export class ContractService {
* Phase 2 — Sign an assembled transaction XDR via the connected wallet.
* Returns the signed XDR string.
*/
async sign(assembledXdr: string, networkPassphrase?: string): Promise<string> {
async sign(
assembledXdr: string,
networkPassphrase?: string,
): Promise<string> {
return this.lifecycle.sign(assembledXdr, networkPassphrase);
}

Expand All @@ -143,44 +165,60 @@ export class ContractService {
* Phase 4 — Poll for transaction confirmation.
* Returns the on-chain return value, tx hash, and ledger.
*/
async poll<T = unknown>(txHash: string, config?: PollConfig): Promise<SubmitResult<T>> {
async poll<T = unknown>(
txHash: string,
config?: PollConfig,
): Promise<SubmitResult<T>> {
return this.lifecycle.poll<T>(txHash, config);
}

/* ---------------- READ ONLY ---------------- */

async simulateReadOnly<T>(method: ContractFnName | string, params: any[]): Promise<TxResponse<T>> {
async simulateReadOnly<T>(
method: ContractFnName | string,
params: any[],
): Promise<TxResponse<T>> {
const sourceKey = this.wallet
? await this.wallet.getPublicKey()
: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF';
: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF";

const account = await this.horizon.loadAccount(sourceKey).catch(() => {
return { accountId: () => sourceKey, sequenceNumber: () => '0' } as any;
return { accountId: () => sourceKey, sequenceNumber: () => "0" } as any;
});

const contract = new Contract(this.contractId);
const tx = new TransactionBuilder(account, {
fee: BASE_FEE,
networkPassphrase: this.networkConfig.networkPassphrase,
})
.addOperation(contract.call(method, ...params.map((p) => this.toScVal(p))))
.addOperation(
contract.call(method, ...params.map((p) => this.toScVal(p))),
)
.setTimeout(30)
.build();

const simResponse = await this.rpc.simulateTransaction(tx);

if (rpc.Api.isSimulationError(simResponse)) {
const errMsg = (simResponse as any).error ?? '';
const code = isExternalSimulationError(errMsg)
? TikkaSdkErrorCode.ExternalContractError
: TikkaSdkErrorCode.SimulationFailed;
throw new TikkaSdkError(
code,
`Read-only simulation of ${method} failed: ${errMsg}`,
const errMsg = (simResponse as any).error ?? "";
const message = `Read-only simulation of ${method} failed: ${errMsg}`;

if (isExternalSimulationError(errMsg)) {
throw new TikkaSdkError(
TikkaSdkErrorCode.ExternalContractError,
message,
errMsg,
);
}

throw (
toTypedContractError(message, errMsg) ??
new TikkaSdkError(TikkaSdkErrorCode.SimulationFailed, message, errMsg)
);
}

const successResp = simResponse as rpc.Api.SimulateTransactionSuccessResponse;
const successResp =
simResponse as rpc.Api.SimulateTransactionSuccessResponse;
const result = successResp.result?.retval;

if (result === undefined) {
Expand All @@ -205,7 +243,10 @@ export class ContractService {
): Promise<TxResponse<T>> {
try {
if (!this.wallet && !options.simulateOnly) {
throw new TikkaSdkError(TikkaSdkErrorCode.WalletNotInstalled, 'Wallet required');
throw new TikkaSdkError(
TikkaSdkErrorCode.WalletNotInstalled,
"Wallet required",
);
}

const sim = await this.lifecycle.simulate<T>(method, params, {
Expand All @@ -218,8 +259,11 @@ export class ContractService {
return { success: true, value: sim.returnValue as T, transactionHash: '', ledger: 0 };
}

const signedXdr = await this.lifecycle.sign(sim.assembledXdr, sim.networkPassphrase);
const txHash = await this.lifecycle.submit(signedXdr);
const signedXdr = await this.lifecycle.sign(
sim.assembledXdr,
sim.networkPassphrase,
);
const txHash = await this.lifecycle.submit(signedXdr);
const polled = await this.lifecycle.poll<T>(txHash, options.poll);

return {
Expand Down Expand Up @@ -250,13 +294,13 @@ export class ContractService {
if (!sourcePublicKey) {
throw new TikkaSdkError(
TikkaSdkErrorCode.InvalidParams,
'sourcePublicKey is required for buildUnsigned',
"sourcePublicKey is required for buildUnsigned",
);
}

const sim = await this.lifecycle.simulate<T>(method, params, {
sourcePublicKey,
fee: feeOverride ? String(feeOverride) : undefined
const sim = await this.lifecycle.simulate<T>(method, params, {
sourcePublicKey,
fee: feeOverride ? String(feeOverride) : undefined,
});
return {
unsignedXdr: sim.assembledXdr,
Expand All @@ -273,7 +317,7 @@ export class ContractService {
if (!signedXdr) {
throw new TikkaSdkError(
TikkaSdkErrorCode.InvalidParams,
'signedXdr is required for submitSigned',
"signedXdr is required for submitSigned",
);
}

Expand All @@ -292,7 +336,7 @@ export class ContractService {
if (!this.wallet && !options.simulateOnly) {
throw new TikkaSdkError(
TikkaSdkErrorCode.WalletNotInstalled,
'Wallet required'
"Wallet required",
);
}

Expand All @@ -303,35 +347,43 @@ export class ContractService {
if (!sourceKey) {
throw new TikkaSdkError(
TikkaSdkErrorCode.InvalidParams,
'Missing source public key'
"Missing source public key",
);
}

const account = await this.horizon.loadAccount(sourceKey);
const contract = new Contract(this.contractId);

let txBuilder = new TransactionBuilder(account, {
fee: options.fee ?? BASE_FEE,
networkPassphrase: this.networkConfig.networkPassphrase,
});

const params = [raffleId];
for (let i = 0; i < count; i++) {
txBuilder = txBuilder.addOperation(contract.call(ContractFn.BUY_TICKET, ...params.map((p) => this.toScVal(p))));
txBuilder = txBuilder.addOperation(
contract.call(
ContractFn.BUY_TICKET,
...params.map((p) => this.toScVal(p)),
),
);
}

const tx = txBuilder.setTimeout(30).build();

const simResponse = await this.rpc.simulateTransaction(tx);

if (rpc.Api.isSimulationError(simResponse)) {
throw new TikkaSdkError(
TikkaSdkErrorCode.SimulationFailed,
`Batch simulation failed`
const errMsg = (simResponse as any).error ?? "";
const message = `Batch simulation failed${errMsg ? `: ${errMsg}` : ""}`;
throw (
toTypedContractError(message, errMsg) ??
new TikkaSdkError(TikkaSdkErrorCode.SimulationFailed, message, errMsg)
);
}

const successSim = simResponse as rpc.Api.SimulateTransactionSuccessResponse;
const successSim =
simResponse as rpc.Api.SimulateTransactionSuccessResponse;
const preparedTx = rpc.assembleTransaction(tx, successSim).build();

// With multiple ops, result is typically an array of results, but for now we just handle it generically.
Expand All @@ -340,42 +392,51 @@ export class ContractService {
: [];

if (options.simulateOnly) {
return { success: true, value: simResult as any, transactionHash: '', ledger: 0 };
return {
success: true,
value: simResult as any,
transactionHash: "",
ledger: 0,
};
}

const { signedXdr } = await this.wallet!.signTransaction(
preparedTx.toXDR(),
{ networkPassphrase: this.networkConfig.networkPassphrase }
{ networkPassphrase: this.networkConfig.networkPassphrase },
);

const signedTx = TransactionBuilder.fromXDR(
signedXdr,
this.networkConfig.networkPassphrase
this.networkConfig.networkPassphrase,
);

const sendResp = await this.rpc.sendTransaction(signedTx);

if (sendResp.status === 'ERROR') {
if (sendResp.status === "ERROR") {
throw new TikkaSdkError(
TikkaSdkErrorCode.SubmissionFailed,
'Batch submission failed'
"Batch submission failed",
);
}

const txResp = await this.rpc.getTransaction(sendResp.hash);

if (txResp.status === rpc.Api.GetTransactionStatus.FAILED) {
throw new TikkaSdkError(
TikkaSdkErrorCode.ContractError,
'Batch transaction failed'
const resultXdr = (txResp as any).resultXdr ?? "";
const message = "Batch transaction failed";
throw (
toTypedContractError(message, resultXdr) ??
new TikkaSdkError(TikkaSdkErrorCode.ContractError, message, resultXdr)
);
}

const successTx = txResp as rpc.Api.GetSuccessfulTransactionResponse;

return {
success: true,
value: (successTx.returnValue ? [scValToNative(successTx.returnValue)] : simResult) as any,
value: (successTx.returnValue
? [scValToNative(successTx.returnValue)]
: simResult) as any,
transactionHash: sendResp.hash,
ledger: successTx.ledger,
};
Expand All @@ -385,7 +446,7 @@ export class ContractService {

private toScVal(val: any): xdr.ScVal {
if (val instanceof xdr.ScVal) return val;
if (typeof val === 'string' && val.length === 56) {
if (typeof val === "string" && val.length === 56) {
return new Address(val).toScVal();
}
return nativeToScVal(val);
Expand Down
Loading
Loading