diff --git a/sdk/package-lock.json b/sdk/package-lock.json index 81af86d..c21e99d 100644 --- a/sdk/package-lock.json +++ b/sdk/package-lock.json @@ -12,8 +12,26 @@ "snarkjs": "^0.7.0" }, "devDependencies": { + "@stellar/stellar-sdk": "^11.0.1", "@types/node": "^20.0.0", + "ts-node": "^10.9.2", "typescript": "^5.0.0" + }, + "peerDependencies": { + "@stellar/stellar-sdk": "^11.0.1" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" } }, "node_modules/@iden3/bigarray": { @@ -32,6 +50,34 @@ "ffjavascript": "^0.3.0" } }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, "node_modules/@noble/hashes": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", diff --git a/sdk/src/__tests__/tx.test.ts b/sdk/src/__tests__/tx.test.ts new file mode 100644 index 0000000..ede9f48 --- /dev/null +++ b/sdk/src/__tests__/tx.test.ts @@ -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); + } + }, +); diff --git a/sdk/src/tx.ts b/sdk/src/tx.ts new file mode 100644 index 0000000..593bf81 --- /dev/null +++ b/sdk/src/tx.ts @@ -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 { + return this.buildPreparedTransaction("register", [ + toScAddress(params.caller), + toScBytes32(params.commitment), + ], params); + } + + public async buildRegisterResolver(params: RegisterResolverParams): Promise { + return this.buildPreparedTransaction("register_resolver", [ + toScAddress(params.caller), + toScBytes32(params.commitment), + toScBytes(params.proof), + toScPublicSignals(params.publicSignals), + ], params); + } + + public async buildAddStellarAddress(params: AddStellarAddressParams): Promise { + return this.buildPreparedTransaction("add_stellar_address", [ + toScAddress(params.caller), + toScBytes32(params.usernameHash), + toScAddress(params.stellarAddress), + ], params); + } + + public async buildResolve(usernameHash: Bytes32Input, options: TxBuildOptions = {}): Promise { + 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); + } + + private async buildPreparedTransaction( + method: BuiltTransaction["method"], + args: xdr.ScVal[], + options: TxBuildOptions & { caller?: string }, + ): Promise { + 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; +}