diff --git a/src/contract_spec.ts b/src/contract_spec.ts index eb48559b8..3f24911f9 100644 --- a/src/contract_spec.ts +++ b/src/contract_spec.ts @@ -6,12 +6,72 @@ import { scValToBigInt, xdr } from 'stellar-base'; +import { + Account, + Operation, + SorobanRpc, + StrKey, + TimeoutInfinite, + TransactionBuilder, + authorizeEntry, + hash, + BASE_FEE, +} from "."; +import type { Memo, MemoType, Transaction } from "."; + +/// Error interface containing the error message +export interface Error_ { message: string }; + +export interface Result { + unwrap(): T, + unwrapErr(): E, + isOk(): boolean, + isErr(): boolean, +}; + +export class Ok implements Result { + constructor(readonly value: T) { } + unwrapErr(): E { + throw new Error('No error'); + } + unwrap(): T { + return this.value; + } + + isOk(): boolean { + return true; + } + + isErr(): boolean { + return !this.isOk() + } +} + +export class Err implements Result { + constructor(readonly error: E) { } + unwrapErr(): E { + return this.error; + } + unwrap(): never { + throw new Error(this.error.message); + } + + isOk(): boolean { + return false; + } + + isErr(): boolean { + return !this.isOk() + } +} export interface Union { tag: string; values?: T; } +export const contractErrorPattern = /Error\(Contract, #(\d+)\)/; + /** * Provides a ContractSpec class which can contains the XDR types defined by the contract. * This allows the class to be used to convert between native and raw `xdr.ScVal`s. @@ -135,7 +195,7 @@ export class ContractSpec { } let output = outputs[0]; if (output.switch().value === xdr.ScSpecType.scSpecTypeResult().value) { - return this.scValToNative(val, output.result().okType()); + return new Ok(this.scValToNative(val, output.result().okType())); } return this.scValToNative(val, output); } @@ -651,6 +711,110 @@ export class ContractSpec { } return num; } + + /** + * Gets the XDR functions from the spec. + * + * @returns {xdr.ScSpecFunctionV0[]} all contract functions + * + */ + funcs(): xdr.ScSpecFunctionV0[] { + return this.entries + .filter( + (entry) => + entry.switch().value === + xdr.ScSpecEntryKind.scSpecEntryFunctionV0().value + ) + .map((entry) => entry.value() as xdr.ScSpecFunctionV0); + } + + /** + * Gets the XDR error cases from the spec. + * + * @returns {xdr.ScSpecFunctionV0[]} all contract functions + * + */ + errorCases(): xdr.ScSpecUdtErrorEnumCaseV0[] { + const errorCases = this.entries.find(entry => + entry.switch().value === + xdr.ScSpecEntryKind.scSpecEntryUdtErrorEnumV0().value + ) as unknown as xdr.ScSpecUdtErrorEnumV0; + return errorCases.cases() + } + + /** + * Generate a class from the contract spec that where each contract method gets included with a possibly-JSified name. + * + * Each method returns an AssembledTransaction object that can be used to sign and submit the transaction. + */ + generateContractClient(options: ContractClientOptions): ContractClient { + const spec = this; + let methods = this.funcs(); + const contractClient = new ContractClient(options); + for (let method of methods) { + let name = method.name().toString(); + let jsName = toLowerCamelCase(name); + // @ts-ignore + contractClient[jsName] = async (args: Record, options: MethodOptions) => { + return await AssembledTransaction.fromSimulation({ + method: name, + args: spec.funcArgsToScVals(name, args), + ...options, + ...contractClient.options, + errorTypes: spec.errorCases().reduce( + (acc, curr) => ({ ...acc, [curr.value()]: { message: curr.doc().toString() } }), + {} as Pick + ), + parseResultXdr: (result: xdr.ScVal) => spec.funcResToNative(name, result), + }); + }; + } + return contractClient; + } +} + +export type XDR_BASE64 = string + +export interface Wallet { + isConnected: () => Promise, + isAllowed: () => Promise, + getUserInfo: () => Promise<{ publicKey?: string }>, + signTransaction: (tx: XDR_BASE64, opts?: { + network?: string, + networkPassphrase?: string, + accountToSign?: string, + }) => Promise, + signAuthEntry: ( + entryXdr: XDR_BASE64, + opts?: { + accountToSign?: string; + } + ) => Promise +} + +export type ContractClientOptions = { + contractId: string + networkPassphrase: string + rpcUrl: string + errorTypes?: Record + /** + * A Wallet interface, such as Freighter, that has the methods `isConnected`, `isAllowed`, `getUserInfo`, and `signTransaction`. If not provided, will attempt to import and use Freighter. Example: + * + * @example + * ```ts + * import freighter from "@stellar/freighter-api"; + * import { Contract } from "test_custom_types"; + * const contract = new Contract({ + * …, + * wallet: freighter, + * }) + * ``` + */ + wallet: Wallet +} + +class ContractClient { + constructor(public readonly options: ContractClientOptions) {} } function stringToScVal(str: string, ty: xdr.ScSpecType): xdr.ScVal { @@ -698,3 +862,627 @@ function readObj(args: object, input: xdr.ScSpecFunctionInputV0): any { } return entry[1]; } + +function implementsToString(obj: unknown): obj is { toString(): string } { + return typeof obj === 'object' && obj !== null && 'toString' in obj; +} + +/** + * converts a snake_case string to camelCase + */ +function toLowerCamelCase(str: string): string { + return str.replace(/_\w/g, (m) => m[1].toUpperCase()); +} + +export type u32 = number; +export type i32 = number; +export type u64 = bigint; +export type i64 = bigint; +export type u128 = bigint; +export type i128 = bigint; +export type u256 = bigint; +export type i256 = bigint; +export type Option = T | undefined; +export type Typepoint = bigint; +export type Duration = bigint; +export {Address}; + +export type Tx = Transaction, Operation[]> + +export class ExpiredStateError extends Error { } +export class NeedsMoreSignaturesError extends Error { } +export class WalletDisconnectedError extends Error { } +export class SendResultOnlyError extends Error { } +export class SendFailedError extends Error { } +export class NoUnsignedNonInvokerAuthEntriesError extends Error { } + +type SendTx = SorobanRpc.Api.SendTransactionResponse; +type GetTx = SorobanRpc.Api.GetTransactionResponse; + +/// Error interface containing the error message +export interface Error_ { message: string }; + +export interface Result { + unwrap(): T, + unwrapErr(): E, + isOk(): boolean, + isErr(): boolean, +}; + +export type MethodOptions = { + /** + * The fee to pay for the transaction. Default: soroban-sdk's BASE_FEE ('100') + */ + fee?: number +} + +type AssembledTransactionOptions = MethodOptions & + ContractClientOptions & { + method: string; + args?: any[]; + parseResultXdr: (xdr: xdr.ScVal) => T; + }; + +export const NULL_ACCOUNT = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF" + +export class AssembledTransaction { + public raw?: Tx + private simulation?: SorobanRpc.Api.SimulateTransactionResponse + private simulationResult?: SorobanRpc.Api.SimulateHostFunctionResult + private simulationTransactionData?: xdr.SorobanTransactionData + private server: SorobanRpc.Server + + toJSON() { + return JSON.stringify({ + method: this.options.method, + tx: this.raw?.toXDR(), + simulationResult: { + auth: this.simulationData.result.auth.map(a => a.toXDR('base64')), + retval: this.simulationData.result.retval.toXDR('base64'), + }, + simulationTransactionData: this.simulationData.transactionData.toXDR('base64'), + }) + } + + static fromJSON( + options: Omit, 'args'>, + { tx, simulationResult, simulationTransactionData }: + { + tx: XDR_BASE64, + simulationResult: { + auth: XDR_BASE64[], + retval: XDR_BASE64, + }, + simulationTransactionData: XDR_BASE64, + } + ): AssembledTransaction { + const txn = new AssembledTransaction(options) + txn.raw = TransactionBuilder.fromXDR(tx, options.networkPassphrase) as Tx + txn.simulationResult = { + auth: simulationResult.auth.map(a => xdr.SorobanAuthorizationEntry.fromXDR(a, 'base64')), + retval: xdr.ScVal.fromXDR(simulationResult.retval, 'base64'), + } + txn.simulationTransactionData = xdr.SorobanTransactionData.fromXDR(simulationTransactionData, 'base64') + return txn + } + + private constructor(public options: AssembledTransactionOptions) { + this.server = new SorobanRpc.Server(this.options.rpcUrl, { + allowHttp: this.options.rpcUrl.startsWith("http://"), + }); + } + + static async fromSimulation(options: AssembledTransactionOptions): Promise> { + const tx = new AssembledTransaction(options) + const contract = new Contract(options.contractId); + + tx.raw = new TransactionBuilder(await tx.getAccount(), { + fee: options.fee?.toString(10) ?? BASE_FEE, + networkPassphrase: options.networkPassphrase, + }) + .addOperation(contract.call(options.method, ...(options.args ?? []))) + .setTimeout(TimeoutInfinite) + .build(); + + return await tx.simulate() + } + + simulate = async (): Promise => { + if (!this.raw) throw new Error('Transaction has not yet been assembled') + this.simulation = await this.server.simulateTransaction(this.raw); + + if (SorobanRpc.Api.isSimulationSuccess(this.simulation)) { + this.raw = SorobanRpc.assembleTransaction( + this.raw, + this.simulation + ).build() + } + + return this + } + + get simulationData(): { + result: SorobanRpc.Api.SimulateHostFunctionResult + transactionData: xdr.SorobanTransactionData + } { + if (this.simulationResult && this.simulationTransactionData) { + return { + result: this.simulationResult, + transactionData: this.simulationTransactionData, + } + } + // else, we know we just did the simulation on this machine + const simulation = this.simulation! + if (SorobanRpc.Api.isSimulationError(simulation)) { + throw new Error(`Transaction simulation failed: "${simulation.error}"`) + } + + if (SorobanRpc.Api.isSimulationRestore(simulation)) { + throw new ExpiredStateError(`You need to restore some contract state before you can invoke this method. ${JSON.stringify(simulation, null, 2)}`) + } + + if (!simulation.result) { + throw new Error(`Expected an invocation simulation, but got no 'result' field. Simulation: ${JSON.stringify(simulation, null, 2)}`) + } + + // add to object for serialization & deserialization + this.simulationResult = simulation.result + this.simulationTransactionData = simulation.transactionData.build() + + return { + result: this.simulationResult, + transactionData: this.simulationTransactionData!, + } + } + + get result(): T { + try { + return this.options.parseResultXdr(this.simulationData.result.retval) + } catch (e) { + if (!implementsToString(e)) throw e + let err = this.parseError(e.toString()) + if (err) return err as T + throw e + } + } + + parseError(errorMessage: string): Err | undefined { + if (!this.options.errorTypes) return undefined + const match = errorMessage.match(contractErrorPattern) + if (!match) return undefined + let i = parseInt(match[1], 10) + let err = this.options.errorTypes[i] + if (!err) return undefined + return new Err(err) + } + + getPublicKey = async (): Promise => { + const wallet = this.options.wallet + if (!await wallet.isConnected() || !await wallet.isAllowed()) { + return undefined + } + return (await wallet.getUserInfo()).publicKey + } + + /** + * Get account details from the Soroban network for the publicKey currently + * selected in user's wallet. If not connected to Freighter, use placeholder + * null account. + */ + getAccount = async (): Promise => { + const publicKey = await this.getPublicKey() + return publicKey + ? await this.server.getAccount(publicKey) + : new Account(NULL_ACCOUNT, "0") + } + + /** + * Sign the transaction with the `wallet` (default Freighter), then send to + * the network and return a `SentTransaction` that keeps track of all the + * attempts to send and fetch the transaction from the network. + */ + signAndSend = async ({ secondsToWait = 10, force = false }: { + /** + * Wait `secondsToWait` seconds (default: 10) for both the transaction to SEND successfully (will keep trying if the server returns `TRY_AGAIN_LATER`), as well as for the transaction to COMPLETE (will keep checking if the server returns `PENDING`). + */ + secondsToWait?: number + /** + * If `true`, sign and send the transaction even if it is a read call. + */ + force?: boolean + } = {}): Promise> => { + if (!this.raw) { + throw new Error('Transaction has not yet been simulated') + } + + if (!force && this.isReadCall) { + throw new Error('This is a read call. It requires no signature or sending. Use `force: true` to sign and send anyway.') + } + + if (!await this.hasRealInvoker()) { + throw new WalletDisconnectedError('Wallet is not connected') + } + + if (this.raw.source !== (await this.getAccount()).accountId()) { + throw new Error(`You must submit the transaction with the account that originally created it. Please switch to the wallet with "${this.raw.source}" as its public key.`) + } + + if ((await this.needsNonInvokerSigningBy()).length) { + throw new NeedsMoreSignaturesError( + 'Transaction requires more signatures. See `needsNonInvokerSigningBy` for details.' + ) + } + + return await SentTransaction.init(this.options, this, secondsToWait); + } + + getStorageExpiration = async () => { + const key = new Contract(this.options.contractId).getFootprint()[1] + + const expirationKey = xdr.LedgerKey.expiration( + new xdr.LedgerKeyExpiration({ keyHash: hash(key.toXDR()) }), + ) + + const entryRes = await this.server.getLedgerEntries(expirationKey) + if (!(entryRes.entries && entryRes.entries.length)) throw new Error('failed to get ledger entry') + + return entryRes.entries[0].val.expiration().expirationLedgerSeq() + } + + /** + * Get a list of accounts, other than the invoker of the simulation, that + * need to sign auth entries in this transaction. + * + * Soroban allows multiple people to sign a transaction. Someone needs to + * sign the final transaction envelope; this person/account is called the + * _invoker_, or _source_. Other accounts might need to sign individual auth + * entries in the transaction, if they're not also the invoker. + * + * This function returns a list of accounts that need to sign auth entries, + * assuming that the same invoker/source account will sign the final + * transaction envelope as signed the initial simulation. + * + * One at a time, for each public key in this array, you will need to + * serialize this transaction with `toJSON`, send to the owner of that key, + * deserialize the transaction with `txFromJson`, and call + * {@link signAuthEntries}. Then re-serialize and send to the next account + * in this list. + */ + needsNonInvokerSigningBy = async ({ + includeAlreadySigned = false, + }: { + /** + * Whether or not to include auth entries that have already been signed. Default: false + */ + includeAlreadySigned?: boolean + } = {}): Promise => { + if (!this.raw) { + throw new Error('Transaction has not yet been simulated') + } + + // We expect that any transaction constructed by these libraries has a + // single operation, which is an InvokeHostFunction operation. The host + // function being invoked is the contract method call. + if (!("operations" in this.raw)) { + throw new Error( + `Unexpected Transaction type; no operations: ${JSON.stringify(this.raw) + }` + ) + } + const rawInvokeHostFunctionOp = this.raw + .operations[0] as Operation.InvokeHostFunction + + return [...new Set((rawInvokeHostFunctionOp.auth ?? []).filter(entry => + entry.credentials().switch() === + xdr.SorobanCredentialsType.sorobanCredentialsAddress() && + ( + includeAlreadySigned || + entry.credentials().address().signature().switch().name === 'scvVoid' + ) + ).map(entry => StrKey.encodeEd25519PublicKey( + entry.credentials().address().address().accountId().ed25519() + )))] + } + + preImageFor( + entry: xdr.SorobanAuthorizationEntry, + signatureExpirationLedger: number + ): xdr.HashIdPreimage { + const addrAuth = entry.credentials().address() + return xdr.HashIdPreimage.envelopeTypeSorobanAuthorization( + new xdr.HashIdPreimageSorobanAuthorization({ + networkId: hash(Buffer.from(this.options.networkPassphrase)), + nonce: addrAuth.nonce(), + invocation: entry.rootInvocation(), + signatureExpirationLedger, + }), + ) + } + + /** + * If {@link needsNonInvokerSigningBy} returns a non-empty list, you can serialize + * the transaction with `toJSON`, send it to the owner of one of the public keys + * in the map, deserialize with `txFromJSON`, and call this method on their + * machine. Internally, this will use `signAuthEntry` function from connected + * `wallet` for each. + * + * Then, re-serialize the transaction and either send to the next + * `needsNonInvokerSigningBy` owner, or send it back to the original account + * who simulated the transaction so they can {@link sign} the transaction + * envelope and {@link send} it to the network. + * + * Sending to all `needsNonInvokerSigningBy` owners in parallel is not currently + * supported! + */ + signAuthEntries = async ( + /** + * When to set each auth entry to expire. Could be any number of blocks in + * the future. Can be supplied as a promise or a raw number. Default: + * contract's current `persistent` storage expiration date/ledger + * number/block. + */ + expiration: number | Promise = this.getStorageExpiration() + ): Promise => { + if (!this.raw) throw new Error('Transaction has not yet been assembled or simulated') + const needsNonInvokerSigningBy = await this.needsNonInvokerSigningBy() + + if (!needsNonInvokerSigningBy) throw new NoUnsignedNonInvokerAuthEntriesError('No unsigned non-invoker auth entries; maybe you already signed?') + const publicKey = await this.getPublicKey() + if (!publicKey) throw new Error('Could not get public key from wallet; maybe Freighter is not signed in?') + if (needsNonInvokerSigningBy.indexOf(publicKey) === -1) throw new Error(`No auth entries for public key "${publicKey}"`) + const wallet = await this.options.wallet + + const rawInvokeHostFunctionOp = this.raw + .operations[0] as Operation.InvokeHostFunction + + const authEntries = rawInvokeHostFunctionOp.auth ?? [] + + for (const [i, entry] of authEntries.entries()) { + if ( + entry.credentials().switch() !== + xdr.SorobanCredentialsType.sorobanCredentialsAddress() + ) { + // if the invoker/source account, then the entry doesn't need explicit + // signature, since the tx envelope is already signed by the source + // account, so only check for sorobanCredentialsAddress + continue + } + const pk = StrKey.encodeEd25519PublicKey( + entry.credentials().address().address().accountId().ed25519() + ) + + // this auth entry needs to be signed by a different account + // (or maybe already was!) + if (pk !== publicKey) continue + + authEntries[i] = await authorizeEntry( + entry, + async preimage => Buffer.from( + await wallet.signAuthEntry(preimage.toXDR('base64')), + 'base64' + ), + await expiration, + this.options.networkPassphrase + ) + } + } + + get isReadCall(): boolean { + const authsCount = this.simulationData.result.auth.length; + const writeLength = this.simulationData.transactionData.resources().footprint().readWrite().length + return (authsCount === 0) && (writeLength === 0); + } + + hasRealInvoker = async (): Promise => { + const account = await this.getAccount() + return account.accountId() !== NULL_ACCOUNT + } +} + +/** + * A transaction that has been sent to the Soroban network. This happens in two steps: + * + * 1. `sendTransaction`: initial submission of the transaction to the network. + * This step can run into problems, and will be retried with exponential + * backoff if it does. See all attempts in `sendTransactionResponseAll` and the + * most recent attempt in `sendTransactionResponse`. + * 2. `getTransaction`: once the transaction has been submitted to the network + * successfully, you need to wait for it to finalize to get the results of the + * transaction. This step can also run into problems, and will be retried with + * exponential backoff if it does. See all attempts in + * `getTransactionResponseAll` and the most recent attempt in + * `getTransactionResponse`. + */ +class SentTransaction { + public server: SorobanRpc.Server + public signed?: Tx + public sendTransactionResponse?: SendTx + public sendTransactionResponseAll?: SendTx[] + public getTransactionResponse?: GetTx + public getTransactionResponseAll?: GetTx[] + + constructor( + public options: AssembledTransactionOptions, + public assembled: AssembledTransaction, + ) { + this.server = new SorobanRpc.Server(this.options.rpcUrl, { + allowHttp: this.options.rpcUrl.startsWith("http://"), + }); + this.assembled = assembled + } + + static init = async ( + options: AssembledTransactionOptions, + assembled: AssembledTransaction, + secondsToWait: number = 10 + ): Promise> => { + const tx = new SentTransaction(options, assembled) + return await tx.send(secondsToWait) + } + + private send = async (secondsToWait: number = 10): Promise => { + const wallet = this.assembled.options.wallet + + this.sendTransactionResponseAll = await withExponentialBackoff( + async (previousFailure) => { + if (previousFailure) { + // Increment transaction sequence number and resimulate before trying again + + // Soroban transaction can only have 1 operation + const op = this.assembled.raw!.operations[0] as Operation.InvokeHostFunction; + + this.assembled.raw = new TransactionBuilder(await this.assembled.getAccount(), { + fee: this.assembled.raw!.fee, + networkPassphrase: this.options.networkPassphrase, + }) + .setTimeout(TimeoutInfinite) + .addOperation( + Operation.invokeHostFunction({ ...op, auth: op.auth ?? [] }), + ) + .build() + + await this.assembled.simulate() + } + + const signature = await wallet.signTransaction(this.assembled.raw!.toXDR(), { + networkPassphrase: this.options.networkPassphrase, + }); + + this.signed = TransactionBuilder.fromXDR( + signature, + this.options.networkPassphrase + ) as Tx + + return this.server.sendTransaction(this.signed) + }, + resp => resp.status !== "PENDING", + secondsToWait + ) + + this.sendTransactionResponse = this.sendTransactionResponseAll[this.sendTransactionResponseAll.length - 1] + + if (this.sendTransactionResponse.status !== "PENDING") { + throw new Error( + `Tried to resubmit transaction for ${secondsToWait + } seconds, but it's still failing. ` + + `All attempts: ${JSON.stringify( + this.sendTransactionResponseAll, + null, + 2 + )}` + ); + } + + const { hash } = this.sendTransactionResponse + + this.getTransactionResponseAll = await withExponentialBackoff( + () => this.server.getTransaction(hash), + resp => resp.status === SorobanRpc.Api.GetTransactionStatus.NOT_FOUND, + secondsToWait + ) + + this.getTransactionResponse = this.getTransactionResponseAll[this.getTransactionResponseAll.length - 1] + if (this.getTransactionResponse.status === SorobanRpc.Api.GetTransactionStatus.NOT_FOUND) { + console.error( + `Waited ${secondsToWait + } seconds for transaction to complete, but it did not. ` + + `Returning anyway. Check the transaction status manually. ` + + `Sent transaction: ${JSON.stringify( + this.sendTransactionResponse, + null, + 2 + )}\n` + + `All attempts to get the result: ${JSON.stringify( + this.getTransactionResponseAll, + null, + 2 + )}` + ); + } + + return this; + } + + get result(): T { + // 1. check if transaction was submitted and awaited with `getTransaction` + if ( + "getTransactionResponse" in this && + this.getTransactionResponse + ) { + // getTransactionResponse has a `returnValue` field unless it failed + if ("returnValue" in this.getTransactionResponse) { + return this.options.parseResultXdr(this.getTransactionResponse.returnValue!) + } + + // if "returnValue" not present, the transaction failed; return without parsing the result + throw new Error("Transaction failed! Cannot parse result.") + } + + // 2. otherwise, maybe it was merely sent with `sendTransaction` + if (this.sendTransactionResponse) { + const errorResult = this.sendTransactionResponse.errorResult?.result() + if (errorResult) { + throw new SendFailedError( + `Transaction simulation looked correct, but attempting to send the transaction failed. Check \`simulation\` and \`sendTransactionResponseAll\` to troubleshoot. Decoded \`sendTransactionResponse.errorResultXdr\`: ${errorResult}` + ) + } + throw new SendResultOnlyError( + `Transaction was sent to the network, but not yet awaited. No result to show. Await transaction completion with \`getTransaction(sendTransactionResponse.hash)\`` + ) + } + + // 3. finally, if neither of those are present, throw an error + throw new Error(`Sending transaction failed: ${JSON.stringify(this.assembled)}`) + } +} + +/** + * Keep calling a `fn` for `secondsToWait` seconds, if `keepWaitingIf` is true. + * Returns an array of all attempts to call the function. + */ +async function withExponentialBackoff( + fn: (previousFailure?: T) => Promise, + keepWaitingIf: (result: T) => boolean, + secondsToWait: number, + exponentialFactor = 1.5, + verbose = false, +): Promise { + const attempts: T[] = [] + + let count = 0 + attempts.push(await fn()) + if (!keepWaitingIf(attempts[attempts.length - 1])) return attempts + + const waitUntil = new Date(Date.now() + secondsToWait * 1000).valueOf() + let waitTime = 1000 + let totalWaitTime = waitTime + + while (Date.now() < waitUntil && keepWaitingIf(attempts[attempts.length - 1])) { + count++ + // Wait a beat + if (verbose) { + console.info(`Waiting ${waitTime}ms before trying again (bringing the total wait time to ${totalWaitTime}ms so far, of total ${secondsToWait * 1000}ms)`) + } + await new Promise(res => setTimeout(res, waitTime)) + // Exponential backoff + waitTime = waitTime * exponentialFactor; + if (new Date(Date.now() + waitTime).valueOf() > waitUntil) { + waitTime = waitUntil - Date.now() + if (verbose) { + console.info(`was gonna wait too long; new waitTime: ${waitTime}ms`) + } + } + totalWaitTime = waitTime + totalWaitTime + // Try again + attempts.push(await fn(attempts[attempts.length - 1])) + if (verbose && keepWaitingIf(attempts[attempts.length - 1])) { + console.info( + `${count}. Called ${fn}; ${attempts.length + } prev attempts. Most recent: ${JSON.stringify(attempts[attempts.length - 1], null, 2) + }` + ) + } + } + + return attempts +} diff --git a/src/index.ts b/src/index.ts index 28750e1f7..f8425309a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,7 +18,7 @@ export * as Horizon from './horizon'; // Soroban RPC-related classes to expose export * as SorobanRpc from './soroban'; -export { ContractSpec } from './contract_spec'; +export * from './contract_spec'; // expose classes and functions from stellar-base export * from 'stellar-base'; diff --git a/test/unit/contract_spec.js b/test/unit/contract_spec.js index a16fbd30f..71e7d4e9e 100644 --- a/test/unit/contract_spec.js +++ b/test/unit/contract_spec.js @@ -6,251 +6,248 @@ const publicKey = "GCBVOLOM32I7OD5TWZQCIXCXML3TK56MDY7ZMTAILIBQHHKPCVU42XYW"; const addr = Address.fromString(publicKey); let SPEC; before(() => { - SPEC = new ContractSpec(spec); + SPEC = new ContractSpec(spec); }); it("throws if no entries", () => { - expect(() => new ContractSpec([])).to.throw( - /Contract spec must have at least one entry/i, - ); + expect(() => new ContractSpec([])).to.throw(/Contract spec must have at least one entry/i); }); describe("Can round trip custom types", function () { - function getResultType(funcName) { - let fn = SPEC.findEntry(funcName).value(); - if (!(fn instanceof xdr.ScSpecFunctionV0)) { - throw new Error("Not a function"); + function getResultType(funcName) { + let fn = SPEC.findEntry(funcName).value(); + if (!(fn instanceof xdr.ScSpecFunctionV0)) { + throw new Error("Not a function"); + } + if (fn.outputs().length === 0) { + return xdr.ScSpecTypeDef.scSpecTypeVoid(); + } + return fn.outputs()[0]; } - if (fn.outputs().length === 0) { - return xdr.ScSpecTypeDef.scSpecTypeVoid(); + function roundtrip(funcName, input, typeName) { + let type = getResultType(funcName); + let ty = typeName ?? funcName; + let obj = {}; + obj[ty] = input; + let scVal = SPEC.funcArgsToScVals(funcName, obj)[0]; + let result = SPEC.funcResToNative(funcName, scVal); + if (type.switch().value === xdr.ScSpecType.scSpecTypeResult().value) { + console.log('about to unwrap', result); + // @ts-ignore + result = result.unwrap(); + } + expect(result).deep.equal(input); } - return fn.outputs()[0]; - } - function roundtrip(funcName, input, typeName) { - let type = getResultType(funcName); - let ty = typeName ?? funcName; - let obj = {}; - obj[ty] = input; - let scVal = SPEC.funcArgsToScVals(funcName, obj)[0]; - let result = SPEC.scValToNative(scVal, type); - expect(result).deep.equal(input); - } - it("u32", () => { - roundtrip("u32_", 1); - }); - it("i32", () => { - roundtrip("i32_", -1); - }); - it("i64", () => { - roundtrip("i64_", 1n); - }); - it("strukt", () => { - roundtrip("strukt", { a: 0, b: true, c: "hello" }); - }); - describe("simple", () => { - it("first", () => { - const simple = { tag: "First", values: undefined }; - roundtrip("simple", simple); + it("u32", () => { + roundtrip("u32_", 1); }); - it("simple second", () => { - const simple = { tag: "Second", values: undefined }; - roundtrip("simple", simple); + it("i32", () => { + roundtrip("i32_", -1); }); - it("simple third", () => { - const simple = { tag: "Third", values: undefined }; - roundtrip("simple", simple); + it("i64", () => { + roundtrip("i64_", 1n); }); - }); - describe("complex", () => { - it("struct", () => { - const complex = { - tag: "Struct", - values: [{ a: 0, b: true, c: "hello" }], - }; - roundtrip("complex", complex); + it("strukt", () => { + roundtrip("strukt", { a: 0, b: true, c: "hello" }); + }); + describe("simple", () => { + it("first", () => { + const simple = { tag: "First", values: undefined }; + roundtrip("simple", simple); + }); + it("simple second", () => { + const simple = { tag: "Second", values: undefined }; + roundtrip("simple", simple); + }); + it("simple third", () => { + const simple = { tag: "Third", values: undefined }; + roundtrip("simple", simple); + }); + }); + describe("complex", () => { + it("struct", () => { + const complex = { + tag: "Struct", + values: [{ a: 0, b: true, c: "hello" }], + }; + roundtrip("complex", complex); + }); + it("tuple", () => { + const complex = { + tag: "Tuple", + values: [ + [ + { a: 0, b: true, c: "hello" }, + { tag: "First", values: undefined }, + ], + ], + }; + roundtrip("complex", complex); + }); + it("enum", () => { + const complex = { + tag: "Enum", + values: [{ tag: "First", values: undefined }], + }; + roundtrip("complex", complex); + }); + it("asset", () => { + const complex = { tag: "Asset", values: [addr, 1n] }; + roundtrip("complex", complex); + }); + it("void", () => { + const complex = { tag: "Void", values: undefined }; + roundtrip("complex", complex); + }); + }); + it('u32_fail_on_even', () => { + roundtrip("u32_fail_on_even", 1, "u32_"); + expect(() => roundtrip("u32_fail_on_even", 2, "u32_")).to.throw(/Please provide an odd number/i); + }); + it("addresse", () => { + roundtrip("addresse", addr); + }); + it("bytes", () => { + const bytes = Buffer.from("hello"); + roundtrip("bytes", bytes); + }); + it("bytes_n", () => { + const bytes_n = Buffer.from("123456789"); // what's the correct way to construct bytes_n? + roundtrip("bytes_n", bytes_n); + }); + it("card", () => { + const card = 11; + roundtrip("card", card); + }); + it("boolean", () => { + roundtrip("boolean", true); + }); + it("not", () => { + roundtrip("boolean", false); + }); + it("i128", () => { + roundtrip("i128", -1n); + }); + it("u128", () => { + roundtrip("u128", 1n); + }); + it("map", () => { + const map = new Map(); + map.set(1, true); + map.set(2, false); + roundtrip("map", map); + map.set(3, "hahaha"); + expect(() => roundtrip("map", map)).to.throw(/invalid type scSpecTypeBool specified for string value/i); + }); + it("vec", () => { + const vec = [1, 2, 3]; + roundtrip("vec", vec); }); it("tuple", () => { - const complex = { - tag: "Tuple", - values: [ - [ - { a: 0, b: true, c: "hello" }, - { tag: "First", values: undefined }, - ], - ], - }; - roundtrip("complex", complex); + const tuple = ["hello", 1]; + roundtrip("tuple", tuple); }); - it("enum", () => { - const complex = { - tag: "Enum", - values: [{ tag: "First", values: undefined }], - }; - roundtrip("complex", complex); + it("option", () => { + roundtrip("option", 1); + roundtrip("option", undefined); }); - it("asset", () => { - const complex = { tag: "Asset", values: [addr, 1n] }; - roundtrip("complex", complex); + it("u256", () => { + roundtrip("u256", 1n); + expect(() => roundtrip("u256", -1n)).to.throw(/expected a positive value, got: -1/i); }); - it("void", () => { - const complex = { tag: "Void", values: undefined }; - roundtrip("complex", complex); + it("i256", () => { + roundtrip("i256", -1n); + }); + it("string", () => { + roundtrip("string", "hello"); + }); + it("tuple_strukt", () => { + const arg = [ + { a: 0, b: true, c: "hello" }, + { tag: "First", values: undefined }, + ]; + roundtrip("tuple_strukt", arg); }); - }); - it("addresse", () => { - roundtrip("addresse", addr); - }); - it("bytes", () => { - const bytes = Buffer.from("hello"); - roundtrip("bytes", bytes); - }); - it("bytes_n", () => { - const bytes_n = Buffer.from("123456789"); // what's the correct way to construct bytes_n? - roundtrip("bytes_n", bytes_n); - }); - it("card", () => { - const card = 11; - roundtrip("card", card); - }); - it("boolean", () => { - roundtrip("boolean", true); - }); - it("not", () => { - roundtrip("boolean", false); - }); - it("i128", () => { - roundtrip("i128", -1n); - }); - it("u128", () => { - roundtrip("u128", 1n); - }); - it("map", () => { - const map = new Map(); - map.set(1, true); - map.set(2, false); - roundtrip("map", map); - map.set(3, "hahaha"); - expect(() => roundtrip("map", map)).to.throw( - /invalid type scSpecTypeBool specified for string value/i, - ); - }); - it("vec", () => { - const vec = [1, 2, 3]; - roundtrip("vec", vec); - }); - it("tuple", () => { - const tuple = ["hello", 1]; - roundtrip("tuple", tuple); - }); - it("option", () => { - roundtrip("option", 1); - roundtrip("option", undefined); - }); - it("u256", () => { - roundtrip("u256", 1n); - expect(() => roundtrip("u256", -1n)).to.throw( - /expected a positive value, got: -1/i, - ); - }); - it("i256", () => { - roundtrip("i256", -1n); - }); - it("string", () => { - roundtrip("string", "hello"); - }); - it("tuple_strukt", () => { - const arg = [ - { a: 0, b: true, c: "hello" }, - { tag: "First", values: undefined }, - ]; - roundtrip("tuple_strukt", arg); - }); }); describe("parsing and building ScVals", function () { - it("Can parse entries", function () { - let spec = new ContractSpec([GIGA_MAP, func]); - let fn = spec.findEntry("giga_map"); - let gigaMap = spec.findEntry("GigaMap"); - expect(gigaMap).deep.equal(GIGA_MAP); - expect(fn).deep.equal(func); - }); + it("Can parse entries", function () { + let spec = new ContractSpec([GIGA_MAP, func]); + let fn = spec.findEntry("giga_map"); + let gigaMap = spec.findEntry("GigaMap"); + expect(gigaMap).deep.equal(GIGA_MAP); + expect(fn).deep.equal(func); + }); }); -export const GIGA_MAP = xdr.ScSpecEntry.scSpecEntryUdtStructV0( - new xdr.ScSpecUdtStructV0({ +export const GIGA_MAP = xdr.ScSpecEntry.scSpecEntryUdtStructV0(new xdr.ScSpecUdtStructV0({ doc: "This is a kitchen sink of all the types", lib: "", name: "GigaMap", fields: [ - new xdr.ScSpecUdtStructFieldV0({ - doc: "", - name: "bool", - type: xdr.ScSpecTypeDef.scSpecTypeBool(), - }), - new xdr.ScSpecUdtStructFieldV0({ - doc: "", - name: "i128", - type: xdr.ScSpecTypeDef.scSpecTypeI128(), - }), - new xdr.ScSpecUdtStructFieldV0({ - doc: "", - name: "u128", - type: xdr.ScSpecTypeDef.scSpecTypeU128(), - }), - new xdr.ScSpecUdtStructFieldV0({ - doc: "", - name: "i256", - type: xdr.ScSpecTypeDef.scSpecTypeI256(), - }), - new xdr.ScSpecUdtStructFieldV0({ - doc: "", - name: "u256", - type: xdr.ScSpecTypeDef.scSpecTypeU256(), - }), - new xdr.ScSpecUdtStructFieldV0({ - doc: "", - name: "i32", - type: xdr.ScSpecTypeDef.scSpecTypeI32(), - }), - new xdr.ScSpecUdtStructFieldV0({ - doc: "", - name: "u32", - type: xdr.ScSpecTypeDef.scSpecTypeU32(), - }), - new xdr.ScSpecUdtStructFieldV0({ - doc: "", - name: "i64", - type: xdr.ScSpecTypeDef.scSpecTypeI64(), - }), - new xdr.ScSpecUdtStructFieldV0({ - doc: "", - name: "u64", - type: xdr.ScSpecTypeDef.scSpecTypeU64(), - }), - new xdr.ScSpecUdtStructFieldV0({ - doc: "", - name: "symbol", - type: xdr.ScSpecTypeDef.scSpecTypeSymbol(), - }), - new xdr.ScSpecUdtStructFieldV0({ - doc: "", - name: "string", - type: xdr.ScSpecTypeDef.scSpecTypeString(), - }), + new xdr.ScSpecUdtStructFieldV0({ + doc: "", + name: "bool", + type: xdr.ScSpecTypeDef.scSpecTypeBool(), + }), + new xdr.ScSpecUdtStructFieldV0({ + doc: "", + name: "i128", + type: xdr.ScSpecTypeDef.scSpecTypeI128(), + }), + new xdr.ScSpecUdtStructFieldV0({ + doc: "", + name: "u128", + type: xdr.ScSpecTypeDef.scSpecTypeU128(), + }), + new xdr.ScSpecUdtStructFieldV0({ + doc: "", + name: "i256", + type: xdr.ScSpecTypeDef.scSpecTypeI256(), + }), + new xdr.ScSpecUdtStructFieldV0({ + doc: "", + name: "u256", + type: xdr.ScSpecTypeDef.scSpecTypeU256(), + }), + new xdr.ScSpecUdtStructFieldV0({ + doc: "", + name: "i32", + type: xdr.ScSpecTypeDef.scSpecTypeI32(), + }), + new xdr.ScSpecUdtStructFieldV0({ + doc: "", + name: "u32", + type: xdr.ScSpecTypeDef.scSpecTypeU32(), + }), + new xdr.ScSpecUdtStructFieldV0({ + doc: "", + name: "i64", + type: xdr.ScSpecTypeDef.scSpecTypeI64(), + }), + new xdr.ScSpecUdtStructFieldV0({ + doc: "", + name: "u64", + type: xdr.ScSpecTypeDef.scSpecTypeU64(), + }), + new xdr.ScSpecUdtStructFieldV0({ + doc: "", + name: "symbol", + type: xdr.ScSpecTypeDef.scSpecTypeSymbol(), + }), + new xdr.ScSpecUdtStructFieldV0({ + doc: "", + name: "string", + type: xdr.ScSpecTypeDef.scSpecTypeString(), + }), ], - }), -); -const GIGA_MAP_TYPE = xdr.ScSpecTypeDef.scSpecTypeUdt( - new xdr.ScSpecTypeUdt({ name: "GigaMap" }), -); -let func = xdr.ScSpecEntry.scSpecEntryFunctionV0( - new xdr.ScSpecFunctionV0({ +})); +const GIGA_MAP_TYPE = xdr.ScSpecTypeDef.scSpecTypeUdt(new xdr.ScSpecTypeUdt({ name: "GigaMap" })); +let func = xdr.ScSpecEntry.scSpecEntryFunctionV0(new xdr.ScSpecFunctionV0({ doc: "Kitchen Sink", name: "giga_map", inputs: [ - new xdr.ScSpecFunctionInputV0({ - doc: "", - name: "giga_map", - type: GIGA_MAP_TYPE, - }), + new xdr.ScSpecFunctionInputV0({ + doc: "", + name: "giga_map", + type: GIGA_MAP_TYPE, + }), ], outputs: [GIGA_MAP_TYPE], - }), -); +})); diff --git a/test/unit/contract_spec.ts b/test/unit/contract_spec.ts index b9481d2a1..a34bc6fc6 100644 --- a/test/unit/contract_spec.ts +++ b/test/unit/contract_spec.ts @@ -35,7 +35,12 @@ describe("Can round trip custom types", function () { let obj: any = {}; obj[ty] = input; let scVal = SPEC.funcArgsToScVals(funcName, obj)[0]; - let result = SPEC.scValToNative(scVal, type); + let result = SPEC.funcResToNative(funcName, scVal); + if (type.switch().value === xdr.ScSpecType.scSpecTypeResult().value) { + console.log('about to unwrap', result); + // @ts-ignore + result = result.unwrap(); + } expect(result).deep.equal(input); } @@ -112,6 +117,11 @@ describe("Can round trip custom types", function () { }); }); + it('u32_fail_on_even', () => { + roundtrip("u32_fail_on_even", 1, "u32_"); + expect(() => roundtrip("u32_fail_on_even", 2, "u32_")).to.throw(/Please provide an odd number/i); + }); + it("addresse", () => { roundtrip("addresse", addr); });