From 204476ddc8b8ed609b3e5c0691922b4745b8a362 Mon Sep 17 00:00:00 2001 From: Chad Ostrowski <221614+chadoh@users.noreply.github.com> Date: Thu, 26 Oct 2023 14:12:27 -0400 Subject: [PATCH] feat: new `signHere` + `submitTx` functionality --- cmd/crates/soroban-spec-typescript/src/lib.rs | 7 +- .../src/project_template/src/index.ts | 5 +- .../src/project_template/src/invoke.ts | 338 +++++++++++++++--- .../ts-tests/initialize.sh | 9 + .../ts-tests/src/test-swap.ts | 192 ++-------- 5 files changed, 347 insertions(+), 204 deletions(-) diff --git a/cmd/crates/soroban-spec-typescript/src/lib.rs b/cmd/crates/soroban-spec-typescript/src/lib.rs index 5e8f8f2101..12794db086 100644 --- a/cmd/crates/soroban-spec-typescript/src/lib.rs +++ b/cmd/crates/soroban-spec-typescript/src/lib.rs @@ -68,11 +68,14 @@ fn generate_class(fns: &[Entry], spec: &[ScSpecEntry]) -> String { .join(",\n "); format!( r#"export class Contract {{ - spec: ContractSpec; + spec: ContractSpec; constructor(public readonly options: ClassOptions) {{ this.spec = new ContractSpec([ {spec} - ]); + ]); + }} + submitTx(txXdr: XDR_BASE64, allSigned: PubKeyToAuthEntries, secondsToWait = 10): Promise {{ + return submitTx({{ txXdr, allSigned, secondsToWait, ...this.options }}); }} {methods} }}"#, 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 2b7c15d242..7ff9a58c12 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 @@ -1,8 +1,9 @@ import * as SorobanClient from 'soroban-client'; import { ContractSpec, Address } from 'soroban-client'; import { Buffer } from "buffer"; -import { invoke } from './invoke.js'; -import type { ResponseTypes, Wallet, ClassOptions } from './method-options.js' +import { invoke, submitTx } from './invoke.js'; +import type { PubKeyToAuthEntries, SubmitResponse } from './invoke.js'; +import type { ResponseTypes, Wallet, ClassOptions, XDR_BASE64 } from './method-options.js' export * from './invoke.js' export * from './method-options.js' diff --git a/cmd/crates/soroban-spec-typescript/src/project_template/src/invoke.ts b/cmd/crates/soroban-spec-typescript/src/project_template/src/invoke.ts index 137f60673e..711bb302c5 100644 --- a/cmd/crates/soroban-spec-typescript/src/project_template/src/invoke.ts +++ b/cmd/crates/soroban-spec-typescript/src/project_template/src/invoke.ts @@ -1,13 +1,21 @@ -import * as SorobanClient from "soroban-client"; -import { SorobanRpc } from "soroban-client"; -import type { +import { Account, - Memo, - MemoType, + Address, + Contract, + Keypair, Operation, - Transaction, - xdr, + Server, + SorobanRpc, + StrKey, + TimeoutInfinite, + TransactionBuilder, + assembleTransaction, + hash, + nativeToScVal, + xdr } from "soroban-client"; +import type { Memo, MemoType, Transaction } from "soroban-client"; +import { Buffer } from "buffer"; import type { ClassOptions, MethodOptions, @@ -24,7 +32,7 @@ export type Tx = Transaction, Operation[]>; */ async function getAccount( wallet: Wallet, - server: SorobanClient.Server + server: Server ): Promise { if (!(await wallet.isConnected()) || !(await wallet.isAllowed())) { return null; @@ -37,6 +45,8 @@ async function getAccount( } export class NotImplementedError extends Error { } +export class ExpiredStateError extends Error { } +export class NeedsMoreSignaturesError extends Error { } type Simulation = SorobanRpc.SimulateTransactionSuccessResponse; type SendTx = SorobanRpc.SendTransactionResponse; @@ -49,6 +59,27 @@ type InvokeArgs = MethodOptions & parseResultXdr: (xdr: string | xdr.ScVal) => T; }; +export type PubKeyToAuthEntries = Map; + +export interface InvokeGeneric { + txUnsigned: XDR_BASE64, + simulation: Simulation, + txSigned?: XDR_BASE64, + sendTransactionResponse?: SendTx, + getTransactionResponse?: GetTx, + getTransactionResponseAll?: GetTx[] + result?: T, +} + +export type InvokeSimulation = Pick, 'txUnsigned' | 'simulation'> & { signHere?: PubKeyToAuthEntries }; + +/** + * might be a read/view call, which means `txSigned`, `sendTx` and `getTx` will all be unnecessary, hence keeping them as optional + */ +export type InvokeFull = Omit, 'result'>; + +export type InvokeParsed = InvokeGeneric & { result: T } + /** * Invoke a method on the test_custom_types contract. * @@ -60,13 +91,12 @@ export async function invoke( args: InvokeArgs ): Promise< R extends "simulated" - ? { txUnsigned: XDR_BASE64, simulation: Simulation } + ? InvokeSimulation : R extends "full" - // might be a read/view call, which means `txSigned`, `sendTx` and `getTx` will all be unnecessary - ? { txUnsigned: XDR_BASE64, simulation: Simulation, txSigned?: XDR_BASE64, sendTransactionResponseAll?: SendTx[], sendTransactionResponse?: SendTx, getTransactionResponse?: GetTx, getTransactionResponseAll?: GetTx[] } + ? InvokeFull : R extends undefined - ? { txUnsigned: XDR_BASE64, simulation: Simulation, txSigned?: XDR_BASE64, sendTransactionResponseAll?: SendTx[], sendTransactionResponse?: SendTx, getTransactionResponse?: GetTx, getTransactionResponseAll?: GetTx[], result: T } - : { txUnsigned: XDR_BASE64, simulation: Simulation, txSigned?: XDR_BASE64, sendTransactionResponseAll?: SendTx[], sendTransactionResponse?: SendTx, getTransactionResponse?: GetTx, getTransactionResponseAll?: GetTx[], result: T } + ? InvokeParsed + : InvokeParsed >; export async function invoke({ method, @@ -79,10 +109,10 @@ export async function invoke({ networkPassphrase, contractId, wallet, -}: InvokeArgs): Promise<{ txUnsigned: XDR_BASE64, txSigned?: XDR_BASE64, simulation: Simulation, result?: T, sendTransactionResponse?: SendTx, getTransactionResponse?: GetTx, getTransactionResponseAll?: GetTx[] }> { +}: InvokeArgs): Promise> { wallet = wallet ?? (await import("@stellar/freighter-api")); let parse = parseResultXdr; - const server = new SorobanClient.Server(rpcUrl, { + const server = new Server(rpcUrl, { allowHttp: rpcUrl.startsWith("http://"), }); const walletAccount = await getAccount(wallet, server); @@ -90,39 +120,52 @@ export async function invoke({ // use a placeholder null account if not yet connected to Freighter so that view calls can still work const account = walletAccount ?? - new SorobanClient.Account( + new Account( "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", "0" ); - const contract = new SorobanClient.Contract(contractId); + const contract = new Contract(contractId); - let txUnsigned = new SorobanClient.TransactionBuilder(account, { + let txUnsigned = new TransactionBuilder(account, { fee: fee.toString(10), networkPassphrase, }) .addOperation(contract.call(method, ...args)) - .setTimeout(SorobanClient.TimeoutInfinite) + .setTimeout(TimeoutInfinite) .build(); const simulation = await server.simulateTransaction(txUnsigned); - txUnsigned = SorobanClient.assembleTransaction(txUnsigned, networkPassphrase, simulation).build() + txUnsigned = assembleTransaction(txUnsigned, networkPassphrase, simulation).build() if (SorobanRpc.isSimulationError(simulation)) { - console.error('it was an error OOOOOOOOOHHHHHH NOOOOOOOOOOO') throw new Error(simulation.error); } if (SorobanRpc.isSimulationRestore(simulation)) { - console.error('got \'restore\' response for simulation:', simulation); - throw new NotImplementedError("Simulation restore not yet supported"); + throw new ExpiredStateError(`You need to restore some contract state before you can invoke this method. ${JSON.stringify(simulation, null, 2)}`); } + + const signHere = await getSignHere(txUnsigned, contractId, server, networkPassphrase); + + if (signHere && responseType !== "simulated") { + throw new NeedsMoreSignaturesError( + "Transaction has Auth Entries that need to be signed by someone other than the invoker! " + + "Use `invoke({ responseType: 'simulation' })` to get a `signHere` object, which will contain public-key indexed list of auth entries. Here's a list of the public keys of the accounts that will need to sign these auth entries:\n\n" + + `- ${Object.keys(signHere).join('\n- ')}` + + "\n\nOnce you update `signHere` with signatures (or construct a new one), you can use `submitTx(unsignedTx, signHere)` to submit the transaction." + ) + } + if (responseType === "simulated") { - return { txUnsigned: txUnsigned.toXDR(), simulation }; + const data: InvokeSimulation = { txUnsigned: txUnsigned.toXDR(), simulation }; + if (signHere) data.signHere = signHere; + return data; } + if (!simulation.result) { throw new Error(`invalid simulation: no result in ${simulation}`); } - let authsCount = simulation.result.auth.length; + const authsCount = simulation.result.auth.length; const writeLength = simulation.transactionData.getReadWrite().length; const isViewCall = (authsCount === 0) && (writeLength === 0); @@ -136,20 +179,6 @@ export async function invoke({ }; } - if (authsCount > 1) { - throw new NotImplementedError("Multiple auths not yet supported"); - } - if (authsCount === 1) { - // TODO: figure out how to fix with new SorobanClient - // const auth = SorobanClient.xdr.SorobanAuthorizationEntry.fromXDR(auths![0]!, 'base64') - // if (auth.addressWithNonce() !== undefined) { - // throw new NotImplementedError( - // `This transaction needs to be signed by ${auth.addressWithNonce() - // }; Not yet supported` - // ) - // } - } - if (!walletAccount) { throw new Error("Not connected to Freighter"); } @@ -206,7 +235,7 @@ export async function signTx( networkPassphrase, }); - return SorobanClient.TransactionBuilder.fromXDR( + return TransactionBuilder.fromXDR( signed, networkPassphrase ) as Tx; @@ -284,7 +313,7 @@ async function withExponentialBackoff( export async function sendTx( tx: Tx, secondsToWait: number, - server: SorobanClient.Server + server: Server ): Promise<{ sendTransactionResponseAll: SendTx[], sendTransactionResponse: SendTx, getTransactionResponse?: GetTx, getTransactionResponseAll?: GetTx[] }> { const sendTransactionResponseAll = await withExponentialBackoff( () => server.sendTransaction(tx), @@ -339,3 +368,230 @@ export async function sendTx( getTransactionResponse: getTransactionResponseAll[getTransactionResponseAll.length - 1] }; } + +export async function getStorageExpiration(contractId: string, storageType: 'temporary' | 'persistent', server: Server) { + const key = xdr.LedgerKey.contractData( + new xdr.LedgerKeyContractData({ + contract: new Address(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 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() +} + +export async function getSignHere( + tx: Tx, + contractId: string, + server: Server, + networkPassphrase: string, +): Promise { + // 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 tx)) { + throw new Error(`tx construction failed; no operations: ${JSON.stringify(tx)}`) + } + const rawInvokeHostFunctionOp = tx + .operations[0] as Operation.InvokeHostFunction + + const authEntries = rawInvokeHostFunctionOp.auth ?? [] + + const needSigningBySomeoneOtherThanInvoker = authEntries.filter(entry => + entry.credentials().switch() === + xdr.SorobanCredentialsType.sorobanCredentialsAddress() + ) + + let signHere: undefined | PubKeyToAuthEntries = undefined + + if (needSigningBySomeoneOtherThanInvoker.length > 0) { + // Set auth entry to expire when contract data expires. + // Could be any number of blocks in the future. + const signatureExpirationLedger = await getStorageExpiration( + contractId, + 'persistent', + server + ) + signHere = needSigningBySomeoneOtherThanInvoker.reduce( + (map, entry) => { + const pk = StrKey.encodeEd25519PublicKey( + entry.credentials().address().address().accountId().ed25519() + ) + const addrAuth = entry.credentials().address() + + const preimage = xdr.HashIdPreimage.envelopeTypeSorobanAuthorization( + new xdr.HashIdPreimageSorobanAuthorization({ + networkId: hash(Buffer.from(networkPassphrase)), + nonce: addrAuth.nonce(), + invocation: entry.rootInvocation(), + signatureExpirationLedger, + }), + ) + const payload = hash(preimage.toXDR()) + map.set(pk, [...map.get(pk) ?? [], payload]) + return map + }, + new Map() as PubKeyToAuthEntries + ) + } + + return signHere +} + +type SubmitArgs = ClassOptions & Pick, 'secondsToWait'> & { + txXdr: XDR_BASE64, + allSigned: PubKeyToAuthEntries, +}; + +/** + * Calling `invoke` might require only a simulation, in the case of view calls, in which case `sendTransaction` is never called. + * `submitTx` always calls `sendTransaction`, so we can mark `sendTransactionResponse` as always being present. + */ +export type SubmitResponse = InvokeFull & { + sendTransactionResponse: SendTx, +}; + +export async function submitTx(args: SubmitArgs): Promise; +export async function submitTx({ + txXdr, + allSigned, + secondsToWait = 10, + contractId, + networkPassphrase, + rpcUrl, + wallet, +}: SubmitArgs): Promise { + const server = new Server(rpcUrl, { + allowHttp: rpcUrl.startsWith("http://"), + }); + wallet = wallet ?? (await import("@stellar/freighter-api")); + + let txUnsigned = TransactionBuilder.fromXDR( + txXdr, + networkPassphrase, + ) + if (txUnsigned.constructor.name === 'FeeBumpTransaction') { + throw new Error(`fee bump transactions are not supported`) + } + + txUnsigned = txUnsigned as Tx + const signHere = await getSignHere(txUnsigned, contractId, server, networkPassphrase) + + if (!signHere) { + throw new Error(`transaction does not need to be signed by anyone other than the invoker; no need to use 'submitTx'`) + } + + for (const [pk, entry] of signHere) { + if (!allSigned.has(pk)) throw new Error(`missing signatures for ${pk}`) + if (entry.length !== allSigned.get(pk)!.length) { + throw new Error( + `missing signatures for ${pk}:\n\n` + + ` \'allSigned.${pk}.length\' = ${allSigned.get(pk)!.length}` + + ` \'signHere.${pk}.length\' = ${entry.length}\n` + ) + } + } + for (const [pk] of allSigned) { + if (!signHere.has(pk)) { + throw new Error(`'allSigned' contains unnecessary signatures for publicKey=${pk}`) + } + } + + const rawInvokeHostFunctionOp = 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 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() + ) + const payload = signHere.get(pk)?.shift()! + const signature = allSigned.get(pk)?.shift()! + if (!Keypair.fromPublicKey(pk).verify(payload, signature)) { + throw new Error( + `Signature doesn't match payload!\n\n` + + ` publicKey: ${pk}\n` + + ` authEntry: ${signature}\n\n` + + `Maybe you provided \`allSigned\` in the wrong order?` + ) + } + + 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])) + + // also need to set the expiration to match the one in signature; + // let's assume persistent storage expiration hasn't changed + const signatureExpirationLedger = await getStorageExpiration( + contractId, + 'persistent', + server + ) + addrAuth.signatureExpirationLedger(signatureExpirationLedger) + + signedAuthEntries.push(entry) + } + } + + const builder = TransactionBuilder.cloneFrom(txUnsigned) + builder.clearOperations().addOperation( + Operation.invokeHostFunction({ + ...rawInvokeHostFunctionOp, + auth: signedAuthEntries, + }), + ) + + const tx = builder.build() + let txSim = await server.simulateTransaction(tx) + if (!SorobanRpc.isSimulationSuccess(txSim)) { + throw new Error('txSim failed!\n\n' + JSON.stringify(txSim, null, 2)) + } + txSim = txSim as SorobanRpc.SimulateTransactionSuccessResponse + + const finalTx = assembleTransaction(tx, networkPassphrase, txSim).build() + const txSigned = await signTx(wallet, finalTx, networkPassphrase); + return { + simulation: txSim, + txUnsigned: txUnsigned.toXDR(), + txSigned: txSigned.toXDR(), + ...await sendTx(txSigned, secondsToWait, server) + }; +} diff --git a/cmd/crates/soroban-spec-typescript/ts-tests/initialize.sh b/cmd/crates/soroban-spec-typescript/ts-tests/initialize.sh index 56749a406e..5e5ee530cf 100755 --- a/cmd/crates/soroban-spec-typescript/ts-tests/initialize.sh +++ b/cmd/crates/soroban-spec-typescript/ts-tests/initialize.sh @@ -53,8 +53,17 @@ function bind_all() { bind contract-id-token-a.txt token } +function mint() { + exe eval "./soroban contract invoke --id $(cat $1) -- mint --amount 2000000 --to $(./soroban config identity address $2)" +} +function mint_all() { + mint contract-id-token-a.txt # to root (blank resolves to default/root address) + mint contract-id-token-b.txt alice +} + curl -X POST "http://localhost:8000/soroban/rpc" || { echo "Make sure you're running standalone RPC network on localhost:8000" && exit 1; } fund_all deploy_all initialize_all +mint_all bind_all 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 c3b93e303c..e291d09e11 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,25 +1,11 @@ import test from "ava" import { wallet, rpcUrl, root, alice, networkPassphrase } from "./util.js" import { Contract as Token } from "token" -import { Contract as Swap, networks, sendTx } from "test-swap" +import { Contract as Swap, networks, NeedsMoreSignaturesError } from "test-swap" import fs from "node:fs" -import { - Address, - Keypair, - Operation, - Server, - SorobanRpc, - StrKey, - TransactionBuilder, - assembleTransaction, - hash, - nativeToScVal, - xdr, -} from 'soroban-client' const tokenAId = fs.readFileSync(new URL("../contract-id-token-a.txt", import.meta.url), "utf8").trim() const tokenBId = fs.readFileSync(new URL("../contract-id-token-b.txt", import.meta.url), "utf8").trim() -const swapId = fs.readFileSync(new URL("../contract-id-swap.txt", import.meta.url), "utf8").trim() const tokenA = new Token({ contractId: tokenAId, @@ -35,15 +21,26 @@ const tokenB = new Token({ }) const swap = new Swap({ ...networks.standalone, rpcUrl, wallet }) -const server = new Server(rpcUrl, { - allowHttp: rpcUrl.startsWith("http://"), -}) - -const amountAToSwap = 10n +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({ + a: root.keypair.publicKey(), + b: alice.keypair.publicKey(), + token_a: tokenAId, + token_b: tokenBId, + amount_a: amountAToSwap, + min_a_for_b: amountAToSwap, + amount_b: amountBToSwap, + min_b_for_a: amountBToSwap, + })) + 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'/) +}) + test('root swaps alice 10 A for 1 B', async t => { - let [ + const [ rootStartingABalance, rootStartingBBalance, aliceStartingABalance, @@ -54,30 +51,10 @@ test('root swaps alice 10 A for 1 B', async t => { (await tokenA.balance({ id: alice.keypair.publicKey() })).result, (await tokenB.balance({ id: alice.keypair.publicKey() })).result, ]) - if (rootStartingABalance < amountAToSwap || aliceStartingBBalance < amountBToSwap) { - // ensure some starting balance for each token - await Promise.all([ - await tokenA.mint({ to: root.keypair.publicKey(), amount: 100n }), - await tokenB.mint({ to: alice.keypair.publicKey(), amount: 100n }), - ]) - - ;[ - rootStartingABalance, - rootStartingBBalance, - aliceStartingABalance, - aliceStartingBBalance, - ] = await Promise.all([ - (await tokenA.balance({ id: root.keypair.publicKey() }) ).result, - (await tokenB.balance({ id: root.keypair.publicKey() }) ).result, - (await tokenA.balance({ id: alice.keypair.publicKey() })).result, - (await tokenB.balance({ id: alice.keypair.publicKey() })).result, - ]) - - t.true(rootStartingABalance >= amountAToSwap, `minting root some A failed! rootStartingABalance: ${rootStartingABalance}`) - t.true(aliceStartingBBalance >= amountBToSwap, `minting alice some B failed! aliceStartingBBalance: ${aliceStartingBBalance}`) - } + t.true(rootStartingABalance >= amountAToSwap, `minting root some A failed! rootStartingABalance: ${rootStartingABalance}`) + t.true(aliceStartingBBalance >= amountBToSwap, `minting alice some B failed! aliceStartingBBalance: ${aliceStartingBBalance}`) - let { txUnsigned } = await swap.swap({ + let { txUnsigned, signHere } = await swap.swap({ a: root.keypair.publicKey(), b: alice.keypair.publicKey(), token_a: tokenAId, @@ -90,100 +67,23 @@ test('root swaps alice 10 A for 1 B', async t => { responseType: 'simulated', }) - let tx = TransactionBuilder.fromXDR( - txUnsigned, - networkPassphrase, - ) - - if (!("operations" in tx)) throw new Error('tx construction failed; no operations') - - const rawInvokeHostFunctionOp = tx - .operations[0] as Operation.InvokeHostFunction - - const authEntries = rawInvokeHostFunctionOp.auth ?? [] - - const signedAuthEntries = [] - - for (const entry of authEntries) { - if ( - // if the source account - entry.credentials().switch() !== - xdr.SorobanCredentialsType.sorobanCredentialsAddress() - ) { - // …then the entry doesn't need explicit signature, - // since the tx envelope is already signed by the source account - signedAuthEntries.push(entry) - } else { - - // else, the entry owner needs to sign - const entryOwner = StrKey.encodeEd25519PublicKey( - entry.credentials().address().address().accountId().ed25519() - ) - t.is(entryOwner, alice.keypair.publicKey()) - - // store a reference to the entry's auth info, which we mutate throughout this block - const addrAuth = entry.credentials().address() - - // set auth entry to expire when contract data expires, but could do any number of blocks in the future - const signatureExpirationLedger = await getStorageExpiration(swapId, 'persistent') - addrAuth.signatureExpirationLedger(signatureExpirationLedger) - - const preimage = xdr.HashIdPreimage.envelopeTypeSorobanAuthorization( - new xdr.HashIdPreimageSorobanAuthorization({ - networkId: hash(Buffer.from(networkPassphrase)), - nonce: addrAuth.nonce(), - invocation: entry.rootInvocation(), - signatureExpirationLedger, - }), - ) - const payload = hash(preimage.toXDR()) - const signature = alice.keypair.sign(payload) - const publicKey = alice.keypair.publicKey() - - t.true(Keypair.fromPublicKey(publicKey).verify(payload, signature), "signature doesn't match payload") - - const sigScVal = nativeToScVal( - { - public_key: StrKey.decodeEd25519PublicKey(publicKey), - 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], - }, - }, - ) - - addrAuth.signature(xdr.ScVal.scvVec([sigScVal])) - - signedAuthEntries.push(entry) - } + if (!signHere) { + t.fail('no signHere entries!') + return } - const builder = TransactionBuilder.cloneFrom(tx) - builder.clearOperations().addOperation( - Operation.invokeHostFunction({ - ...rawInvokeHostFunctionOp, - auth: signedAuthEntries, - }), - ) - - tx = builder.build() - let txSim = await server.simulateTransaction(tx) + const signatures: Map = new Map() + for (const [pk, entries] of signHere) { + t.is(pk, alice.keypair.publicKey()) + t.is(entries.length, 1) - if (!SorobanRpc.isSimulationSuccess(txSim)) { - t.log('txSim', txSim) - t.fail('txSim failed!') + for (const entry of entries) { + const signature = alice.keypair.sign(entry) + signatures.set(pk, [...signatures.get(pk) ?? [], signature]) + } } - txSim = txSim as SorobanRpc.SimulateTransactionSuccessResponse - const finalTx = assembleTransaction(tx, networkPassphrase, txSim).build() - finalTx.sign(root.keypair) - const result = await sendTx(finalTx, 10, server) + const result = await swap.submitTx(txUnsigned, signatures) t.truthy(result.sendTransactionResponse, `tx failed: ${JSON.stringify(result)}`) t.true(result.sendTransactionResponse.status === 'PENDING', `tx failed: ${JSON.stringify(result)}`) @@ -207,29 +107,3 @@ test('root swaps alice 10 A for 1 B', async t => { aliceStartingBBalance - amountBToSwap ) }) - -async function getStorageExpiration(contractId: string, storageType: 'temporary' | 'persistent') { - const key = xdr.LedgerKey.contractData( - new xdr.LedgerKeyContractData({ - contract: new Address(contractId).toScAddress(), - key: xdr.ScVal.scvLedgerKeyContractInstance(), - durability: xdr.ContractDataDurability[storageType](), - }), - ) - - const expirationKey = xdr.LedgerKey.expiration( - new xdr.LedgerKeyExpiration({ keyHash: hash(key.toXDR()) }), - ) - - // Fetch the current contract ledger seq - // eslint-disable-next-line no-await-in-loop - const entryRes = await 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", - ) - // set auth entry to expire when contract data expires, but could any number of blocks in the future - return parsed.expiration().expirationLedgerSeq() -}