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
46 changes: 46 additions & 0 deletions sdk/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

52 changes: 52 additions & 0 deletions sdk/src/__tests__/tx.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import assert from "node:assert/strict";
import test from "node:test";

import { Keypair, Networks, TransactionBuilder } from "@stellar/stellar-sdk";

import { StellarTxBuilder } from "../tx";

const env = {
rpcUrl: process.env.STELLAR_RPC_URL,
networkPassphrase: process.env.STELLAR_NETWORK_PASSPHRASE ?? Networks.TESTNET,
contractAddress: process.env.STELLAR_CORE_CONTRACT_ADDRESS,
sourceSecret: process.env.STELLAR_SOURCE_SECRET,
};

const shouldSkip = !env.rpcUrl || !env.contractAddress || !env.sourceSecret;

test(
"StellarTxBuilder builds valid Soroban XDR envelopes",
{
skip: shouldSkip
? "Set STELLAR_RPC_URL, STELLAR_CORE_CONTRACT_ADDRESS, and STELLAR_SOURCE_SECRET to run this integration test"
: false,
},
async () => {
const source = Keypair.fromSecret(env.sourceSecret!).publicKey();
const builder = new StellarTxBuilder({
rpcUrl: env.rpcUrl!,
networkPassphrase: env.networkPassphrase,
contractAddress: env.contractAddress!,
defaultSource: source,
});

const bytes32 = new Uint8Array(32).fill(7);

const registerTx = await builder.buildRegister({
caller: source,
commitment: bytes32,
});
const addAddressTx = await builder.buildAddStellarAddress({
caller: source,
usernameHash: bytes32,
stellarAddress: source,
});
const resolveTx = await builder.buildResolve(bytes32);

for (const built of [registerTx, addAddressTx, resolveTx]) {
const parsed = TransactionBuilder.fromXDR(built.xdr, env.networkPassphrase);
assert.equal(parsed.toXDR(), built.xdr);
assert.ok(built.xdr.length > 0);
}
},
);
166 changes: 166 additions & 0 deletions sdk/src/tx.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import {
Address,
BASE_FEE,
Contract,
Keypair,
SorobanRpc,
TransactionBuilder,
nativeToScVal,
type xdr,
} from "@stellar/stellar-sdk";

import type {
AddStellarAddressParams,
BinaryInput,
BuiltTransaction,
Bytes32Input,
PublicSignalsInput,
RegisterParams,
RegisterResolverParams,
StellarTxBuilderConfig,
SubmitTransactionOptions,
TxBuildOptions,
} from "./types";

const DEFAULT_TIMEOUT_SECONDS = 60;

export class StellarTxBuilder {
private readonly server: SorobanRpc.Server;
private readonly contract: Contract;

public constructor(private readonly config: StellarTxBuilderConfig) {
this.server = new SorobanRpc.Server(config.rpcUrl, {
allowHttp: config.allowHttp ?? isHttpUrl(config.rpcUrl),
});
this.contract = new Contract(config.contractAddress);
}

public async buildRegister(params: RegisterParams): Promise<BuiltTransaction> {
return this.buildPreparedTransaction("register", [
toScAddress(params.caller),
toScBytes32(params.commitment),
], params);
}

public async buildRegisterResolver(params: RegisterResolverParams): Promise<BuiltTransaction> {
return this.buildPreparedTransaction("register_resolver", [
toScAddress(params.caller),
toScBytes32(params.commitment),
toScBytes(params.proof),
toScPublicSignals(params.publicSignals),
], params);
}

public async buildAddStellarAddress(params: AddStellarAddressParams): Promise<BuiltTransaction> {
return this.buildPreparedTransaction("add_stellar_address", [
toScAddress(params.caller),
toScBytes32(params.usernameHash),
toScAddress(params.stellarAddress),
], params);
}

public async buildResolve(usernameHash: Bytes32Input, options: TxBuildOptions = {}): Promise<BuiltTransaction> {
return this.buildPreparedTransaction("resolve_stellar", [toScBytes32(usernameHash)], options);
}

public async submitTransaction(
built: BuiltTransaction | string,
signer: Keypair | string,
_options: SubmitTransactionOptions = {},
) {
const signed = typeof built === "string"
? TransactionBuilder.fromXDR(built, this.config.networkPassphrase)
: TransactionBuilder.fromXDR(built.xdr, this.config.networkPassphrase);
const keypair = typeof signer === "string" ? Keypair.fromSecret(signer) : signer;

signed.sign(keypair);

return this.server.sendTransaction(signed);
}
Comment on lines +66 to +79
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

SubmitTransactionOptions.pollUntilSuccess is declared but not used.

The _options parameter includes pollUntilSuccess (per types.ts), but the implementation ignores it and returns immediately after sendTransaction. This could leave callers expecting confirmation polling that never happens.

🔧 Proposed implementation for pollUntilSuccess
   public async submitTransaction(
     built: BuiltTransaction | string,
     signer: Keypair | string,
-    _options: SubmitTransactionOptions = {},
+    options: SubmitTransactionOptions = {},
   ) {
     const signed = typeof built === "string"
       ? TransactionBuilder.fromXDR(built, this.config.networkPassphrase)
       : TransactionBuilder.fromXDR(built.xdr, this.config.networkPassphrase);
     const keypair = typeof signer === "string" ? Keypair.fromSecret(signer) : signer;
 
     signed.sign(keypair);
 
-    return this.server.sendTransaction(signed);
+    const response = await this.server.sendTransaction(signed);
+    
+    if (options.pollUntilSuccess && response.status === "PENDING") {
+      // Poll for transaction result
+      const hash = response.hash;
+      let result = await this.server.getTransaction(hash);
+      while (result.status === "NOT_FOUND") {
+        await new Promise((resolve) => setTimeout(resolve, 1000));
+        result = await this.server.getTransaction(hash);
+      }
+      return result;
+    }
+    
+    return response;
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@zk/sdk/src/tx.ts` around lines 70 - 83, The submitTransaction implementation
ignores the SubmitTransactionOptions.pollUntilSuccess flag; update
submitTransaction (and respect _options.pollUntilSuccess) so that after signing
and calling this.server.sendTransaction you extract the returned transaction
hash/id and, when pollUntilSuccess is true, poll the network (using existing
server methods such as a transaction status endpoint like getTransaction/getTx
or waitForTransaction) until the transaction reaches a success or final failure
state, then return the final confirmed result; if pollUntilSuccess is false,
keep the current behavior and return the immediate sendTransaction response.
Ensure you reference submitTransaction, SubmitTransactionOptions,
pollUntilSuccess, and this.server.sendTransaction when making the changes.


private async buildPreparedTransaction(
method: BuiltTransaction["method"],
args: xdr.ScVal[],
options: TxBuildOptions & { caller?: string },
): Promise<BuiltTransaction> {
const source = resolveSource(options, this.config.defaultSource);
if (!source) {
throw new Error(`A source account is required to build ${method} transactions`);
}

const account = await this.server.getAccount(source);
const raw = new TransactionBuilder(account, {
fee: options.fee ?? this.config.defaultFee ?? BASE_FEE,
networkPassphrase: this.config.networkPassphrase,
})
.addOperation(this.contract.call(method, ...args))
.setTimeout(options.timeoutInSeconds ?? this.config.timeoutInSeconds ?? DEFAULT_TIMEOUT_SECONDS)
.build();

const prepared = await this.server.prepareTransaction(raw);

return {
transaction: prepared,
xdr: prepared.toXDR(),
method,
source,
};
}
}

function toScAddress(address: string): xdr.ScVal {
return new Address(address).toScVal();
}

function toScBytes32(value: Bytes32Input): xdr.ScVal {
const bytes = normalizeBytes(value);
if (bytes.length !== 32) {
throw new Error(`Expected 32 bytes, received ${bytes.length}`);
}

return nativeToScVal(bytes, { type: "bytes" });
}

function toScBytes(value: BinaryInput): xdr.ScVal {
return nativeToScVal(normalizeBytes(value), { type: "bytes" });
}

function toScPublicSignals(value: PublicSignalsInput): xdr.ScVal {
return nativeToScVal({
old_root: normalizeBytes32(value.oldRoot),
new_root: normalizeBytes32(value.newRoot),
});
}

function normalizeBytes32(value: Bytes32Input): Buffer {
const bytes = normalizeBytes(value);
if (bytes.length !== 32) {
throw new Error(`Expected 32 bytes, received ${bytes.length}`);
}

return bytes;
}

function normalizeBytes(value: string | Uint8Array): Buffer {
if (typeof value !== "string") {
return Buffer.from(value);
}

const normalized = value.startsWith("0x") ? value.slice(2) : value;
if (normalized.length % 2 === 0 && /^[0-9a-fA-F]+$/.test(normalized)) {
return Buffer.from(normalized, "hex");
}

return Buffer.from(value, "base64");
}

function isHttpUrl(url: string): boolean {
return url.startsWith("http://");
}

function resolveSource(
options: TxBuildOptions & { caller?: string },
defaultSource?: string,
): string | undefined {
return options.source ?? options.caller ?? defaultSource;
}
Loading