diff --git a/cmd/crates/soroban-spec-typescript/src/project_template/src/assembled-tx.ts b/cmd/crates/soroban-spec-typescript/src/project_template/src/assembled-tx.ts index dd2e91b44b..a797325306 100644 --- a/cmd/crates/soroban-spec-typescript/src/project_template/src/assembled-tx.ts +++ b/cmd/crates/soroban-spec-typescript/src/project_template/src/assembled-tx.ts @@ -1,12 +1,17 @@ import { Account, + Address, Contract, + Keypair, Operation, Server, SorobanRpc, + StrKey, TimeoutInfinite, TransactionBuilder, assembleTransaction, + hash, + nativeToScVal, xdr } from "soroban-client"; import type { Memo, MemoType, Transaction } from "soroban-client"; @@ -25,6 +30,7 @@ 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.SendTransactionResponse; type GetTx = SorobanRpc.GetTransactionResponse; @@ -50,6 +56,7 @@ export class AssembledTransaction { public getTransactionResponse?: GetTx public getTransactionResponseAll?: GetTx[] public server: Server + public signatureExpirationLedger?: number static async fromSimulation(options: AssembledTransactionClassOptions): Promise> { const tx = new AssembledTransaction(options) @@ -146,6 +153,12 @@ export class AssembledTransaction { throw new WalletDisconnectedError('Wallet is not connected') } + if (await this.nonInvokerAuthEntries()) { + throw new NeedsMoreSignaturesError( + 'Transaction requires more signatures. See `nonInvokerAuthEntries` for details.' + ) + } + // throw errors if transaction needs restore first or if it's otherwise not an expected simulation type this.ensureSuccessfulHostFunctionSimulation() @@ -233,6 +246,250 @@ export class AssembledTransaction { return this; } + getStorageExpiration = async (storageType: 'temporary' | 'persistent') => { + const key = xdr.LedgerKey.contractData( + new xdr.LedgerKeyContractData({ + contract: new Address(this.options.contractId).toScAddress(), + key: xdr.ScVal.scvLedgerKeyContractInstance(), + durability: xdr.ContractDataDurability[storageType](), + }), + ) + + 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') + + const parsed = xdr.LedgerEntryData.fromXDR( + entryRes.entries[0].xdr, + "base64", + ) + return parsed.expiration().expirationLedgerSeq() + } + + /** + * Get a Map of public keys to preimages for all auth entries in this + * transaction that need to be signed by someone other than the + * invoker/source/signer of the initial simulation, and which haven't already + * been signed. + * + * Assumes that the same invoker/source account will sign the final + * transaction envelope. + * + * One at a time, for each key in this object, serialize the transaction, + * send to the owner of that key, and call + * `tx.signAuthEntriesFor(theirPublicKey, signingFunction)`. + */ + nonInvokerAuthEntries = async ({ + includeAlreadySigned = false, + expiration = this.signatureExpirationLedger ?? + this.getStorageExpiration('persistent') + }: { + /** + * Whether or not to include auth entries that have already been signed. Default: false + */ + includeAlreadySigned?: boolean + /** + * 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. + + * Will be saved to the object so it can be serialized/deserialized and + * used in future operations on other machines, such as calls to + * `signAuthEntriesFor` + */ + expiration?: number | Promise + } = {}): Promise => { + if (!this.txUnsigned) { + 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 ( + !this.ensureSuccessfulHostFunctionSimulation() || + !("operations" in this.txUnsigned) + ) { + throw new Error( + `Unexpected Transaction type; no operations: ${ + JSON.stringify(this.txUnsigned) + }` + ) + } + const rawInvokeHostFunctionOp = this.txUnsigned + .operations[0] as Operation.InvokeHostFunction + + const authEntries = rawInvokeHostFunctionOp.auth ?? [] + + const nonSourceAuthEntriesFlat = authEntries.filter(entry => + entry.credentials().switch() === + xdr.SorobanCredentialsType.sorobanCredentialsAddress() && + ( + includeAlreadySigned || + entry.credentials().address().signature().switch().name === 'scvVoid' + ) + ) + + let nonSourceAuthEntries: undefined | PubKeyToPreimagesUnsigned = undefined + + if (nonSourceAuthEntriesFlat.length > 0) { + this.signatureExpirationLedger = await expiration + nonSourceAuthEntries = nonSourceAuthEntriesFlat.reduce( + (map, entry) => { + const pk = StrKey.encodeEd25519PublicKey( + entry.credentials().address().address().accountId().ed25519() + ) + const preimage = this.preImageFor(entry) + map.set(pk, [...map.get(pk) ?? [], preimage]) + return map + }, + new Map() as PubKeyToPreimagesUnsigned + ) + } + + return nonSourceAuthEntries + } + + preImageFor(entry: xdr.SorobanAuthorizationEntry): 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: this.signatureExpirationLedger!, + }), + ) + } + + /** + * If `this.nonInvokerAuthEntries` returns a non-empty map, you can serialize + * the transaction (TODO: how?), send it to the owner of one of the public keys + * in the map, and call this method on their machine, passing in the function to + * use to sign each of their auth entries. + * + * Then re-serialize the transaction and send it back to the original + * machine, where it can either be sent to the next `nonInvokerAuthEntries` + * owner, or submitted to the network. + * + * Sending to all `nonInvokerAuthEntries` owners in parallel is not currently + * supported! + */ + signAuthEntriesFor = async ( + /** + * The public key of the account with auth entries that need to be signed. + * + * Must correspond to a key from `await this.nonInvokerAuthEntries()`. + */ + publicKey: string, + /** + * Signing function to use to sign each auth entry that `publicKey` account + holder needs to sign. + */ + sign: (payload: Buffer) => Buffer | Promise + ) => { + if (!this.txUnsigned) throw new Error('Transaction has not yet been assembled or simulated') + const nonInvokerAuthEntries = await this.nonInvokerAuthEntries() + + if (!nonInvokerAuthEntries) throw new NoUnsignedNonInvokerAuthEntriesError('No unsigned non-invoker auth entries; maybe you already signed?') + if (!nonInvokerAuthEntries.has(publicKey)) throw new Error('No auth entries for this public key') + + const rawInvokeHostFunctionOp = this.txUnsigned + .operations[0] as Operation.InvokeHostFunction + + const originalAuthEntries = rawInvokeHostFunctionOp.auth ?? [] + + const signedAuthEntries: xdr.SorobanAuthorizationEntry[] = [] + + for (const entry of originalAuthEntries) { + 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 + signedAuthEntries.push(entry) + } else { + const pk = StrKey.encodeEd25519PublicKey( + entry.credentials().address().address().accountId().ed25519() + ) + if (pk !== publicKey) { + // this auth entry needs to be signed by a different account + // (or maybe already was!) + signedAuthEntries.push(entry) + } else { + const preimage = this.preImageFor(entry) + const payload = hash(preimage.toXDR()) + const signature = await sign(payload) + + // TODO: do we need this check? + if (!Keypair.fromPublicKey(pk).verify(payload, signature)) { + throw new Error( + `Signature doesn't match payload!\n\n` + + ` publicKey: ${pk}\n` + + ` signature: ${signature}` + ) + } + + const sigScVal = nativeToScVal( + { + public_key: StrKey.decodeEd25519PublicKey(pk), + signature, + }, + { + // force the keys to be interpreted as symbols (expected for + // Soroban [contracttype]s) + // Pr open to fix this type in the gen'd xdr + type: { + public_key: ["symbol", null], + signature: ["symbol", null], + }, + }, + ) + // get a reference to the auth entry, then mutate it + const addrAuth = entry.credentials().address() + addrAuth.signature(xdr.ScVal.scvVec([sigScVal])) + + addrAuth.signatureExpirationLedger( + preimage.sorobanAuthorization().signatureExpirationLedger() + ) + + signedAuthEntries.push(entry) + } + } + } + + const builder = TransactionBuilder.cloneFrom(this.txUnsigned) + builder.clearOperations().addOperation( + Operation.invokeHostFunction({ + ...rawInvokeHostFunctionOp, + auth: signedAuthEntries, + }), + ) + + this.txUnsigned = builder.build() + + // if these were the final signatures needed, resimulate & reassemble + if (!await this.nonInvokerAuthEntries()) { + let txSim = await this.server.simulateTransaction(this.txUnsigned) + if (!SorobanRpc.isSimulationSuccess(txSim)) { + throw new Error('Resimulating the transaction failed!\n\n' + JSON.stringify(txSim, null, 2)) + } + txSim = txSim as SorobanRpc.SimulateTransactionSuccessResponse + + this.txUnsigned = assembleTransaction( + this.txUnsigned, + this.options.networkPassphrase, + txSim + ).build() + } + } + get isViewCall(): boolean { const simulation = this.ensureSuccessfulHostFunctionSimulation() const authsCount = simulation.result.auth.length; diff --git a/cmd/crates/soroban-spec-typescript/src/project_template/src/index.ts b/cmd/crates/soroban-spec-typescript/src/project_template/src/index.ts index e068799066..40ce397ee8 100644 --- a/cmd/crates/soroban-spec-typescript/src/project_template/src/index.ts +++ b/cmd/crates/soroban-spec-typescript/src/project_template/src/index.ts @@ -6,11 +6,9 @@ import { invoke, submitTx } from './invoke.js'; import type { PubKeyToPreimagesUnsigned, PubKeyToPreimagesSigned, SubmitResponse } from './invoke.js'; import type { ResponseTypes, Wallet, ClassOptions, XDR_BASE64 } from './method-options.js'; -export * from './invoke.js'; +export * from './assembled-tx.js'; export * from './method-options.js'; -export { hash } from 'soroban-client'; - export type u32 = number; export type i32 = number; export type u64 = bigint; diff --git a/cmd/crates/soroban-spec-typescript/ts-tests/src/test-custom-types.ts b/cmd/crates/soroban-spec-typescript/ts-tests/src/test-custom-types.ts index c061abc0cd..34817ef53a 100644 --- a/cmd/crates/soroban-spec-typescript/ts-tests/src/test-custom-types.ts +++ b/cmd/crates/soroban-spec-typescript/ts-tests/src/test-custom-types.ts @@ -8,7 +8,8 @@ const publicKey = root.keypair.publicKey(); const contract = new Contract({ ...networks.standalone, rpcUrl, wallet }); test('hello', async t => { - t.is((await contract.hello({ hello: 'tests' })).result, 'tests') + const tx = await contract.hello({ hello: 'tests' }) + t.is(tx.result, 'tests') }) test('woid', async t => { diff --git a/cmd/crates/soroban-spec-typescript/ts-tests/src/test-swap.ts b/cmd/crates/soroban-spec-typescript/ts-tests/src/test-swap.ts index 0627d72399..3b7f63e075 100644 --- a/cmd/crates/soroban-spec-typescript/ts-tests/src/test-swap.ts +++ b/cmd/crates/soroban-spec-typescript/ts-tests/src/test-swap.ts @@ -1,7 +1,7 @@ import test from "ava" import { wallet, rpcUrl, root, alice, networkPassphrase } from "./util.js" import { ContractV2 as Token } from "token" -import { Contract as Swap, networks, NeedsMoreSignaturesError, hash } from "test-swap" +import { ContractV2 as Swap, networks, NeedsMoreSignaturesError } from "test-swap" import fs from "node:fs" const tokenAId = fs.readFileSync(new URL("../contract-id-token-a.txt", import.meta.url), "utf8").trim() @@ -24,8 +24,8 @@ const swap = new Swap({ ...networks.standalone, rpcUrl, wallet }) const amountAToSwap = 2n const amountBToSwap = 1n -test('attempting to call `swap` without `responseType: "simulated"` throws a descriptive error', async t => { - const error = await t.throwsAsync(swap.swap({ +test('attempting to call `swap.sign()` before signing other auth entries throws a descriptive error', async t => { + const tx = await swap.swap({ a: root.keypair.publicKey(), b: alice.keypair.publicKey(), token_a: tokenAId, @@ -34,9 +34,10 @@ test('attempting to call `swap` without `responseType: "simulated"` throws a des min_a_for_b: amountAToSwap, amount_b: amountBToSwap, min_b_for_a: amountBToSwap, - })) + }) + const error = await t.throwsAsync(tx.sign()) t.true(error instanceof NeedsMoreSignaturesError, `error is not of type 'NeedsMoreSignaturesError'; instead it is of type '${error?.constructor.name}'`) - if (error) t.regex(error.message, /responseType: 'simulation'/) + if (error) t.regex(error.message, /nonInvokerAuthEntries/) }) test('root swaps alice 10 A for 1 B', async t => { @@ -54,7 +55,7 @@ test('root swaps alice 10 A for 1 B', async t => { t.true(rootStartingABalance >= amountAToSwap, `root does not have enough Token A! rootStartingABalance: ${rootStartingABalance}`) t.true(aliceStartingBBalance >= amountBToSwap, `alice does not have enough Token B! aliceStartingBBalance: ${aliceStartingBBalance}`) - let { txUnsigned, signHere } = await swap.swap({ + let tx = await swap.swap({ a: root.keypair.publicKey(), b: alice.keypair.publicKey(), token_a: tokenAId, @@ -63,31 +64,30 @@ test('root swaps alice 10 A for 1 B', async t => { min_a_for_b: amountAToSwap, amount_b: amountBToSwap, min_b_for_a: amountBToSwap, - }, { - responseType: 'simulated', }) - if (!signHere) { - t.fail('no signHere entries!') + // TODO: serialize & save tx to exercise reserialize, sign, & send + + const nonInvokerAuthEntries = await tx.nonInvokerAuthEntries() + + if (!nonInvokerAuthEntries) { + t.fail('no nonInvokerAuthEntries!') return } - const signatures: Map = new Map() - for (const [pk, preimages] of signHere) { - t.is(pk, alice.keypair.publicKey()) - t.is(preimages.length, 1) + t.is(nonInvokerAuthEntries.size, 1) - for (const preimage of preimages) { - const payload = hash(preimage.toXDR()) - const signature = alice.keypair.sign(payload) - signatures.set(pk, [...signatures.get(pk) ?? [], signature]) - } - } + const pk = alice.keypair.publicKey() + t.true(nonInvokerAuthEntries.has(pk), 'nonInvokerAuthEntries does not have alice\'s public key!') + t.is(nonInvokerAuthEntries.get(pk)?.length, 1) + + tx.signAuthEntriesFor(pk, payload => alice.keypair.sign(payload)) - const result = await swap.submitTx(txUnsigned, signHere, signatures) + await tx.sign() // sign transaction envelope as source account (root) + const result = await tx.send() t.truthy(result.sendTransactionResponse, `tx failed: ${JSON.stringify(result)}`) - t.true(result.sendTransactionResponse.status === 'PENDING', `tx failed: ${JSON.stringify(result)}`) + t.true(result.sendTransactionResponse!.status === 'PENDING', `tx failed: ${JSON.stringify(result)}`) t.truthy(result.getTransactionResponseAll?.length, `tx failed: ${JSON.stringify(result)}`) t.truthy(result.getTransactionResponse, `tx failed: ${JSON.stringify(result)}`)