From 40f2175f56171d03c953e08d337f2f90c04a7f10 Mon Sep 17 00:00:00 2001 From: Blaine Heffron Date: Wed, 13 Nov 2024 18:03:46 -0500 Subject: [PATCH] Update signTransaction and signAuthEntries to match SEP-43 (#1097) * add documentation for new args * modify basic node signer to match new Sep43 spec * add changelog, proper error type for basic node signer --------- Co-authored-by: Chad Ostrowski <221614+chadoh@users.noreply.github.com> --- CHANGELOG.md | 5 ++ src/contract/assembled_transaction.ts | 72 ++++++++++++++++++---- src/contract/basic_node_signer.ts | 26 +++++--- src/contract/types.ts | 88 ++++++++++++++++++++++----- 4 files changed, 157 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab7a880c3..bf6d5a991 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ A breaking change will get clearly marked in this log. ## Unreleased +### Breaking Changes +- The `ClientOptions.signTransaction` type has been updated to reflect the latest [SEP 43](https://github.com/stellar/stellar-protocol/blob/eb401f932258c827a5b4a2e14aea939affcd2b02/ecosystem/sep-0043.md#wallet-interface-format) protocol, which matches the latest major version of Freighter and other wallets. It now accepts `address`, `submit`, and `submitUrl` options, and it returns a promise containing the `signedTxXdr` and the `signerAddress`. It now also returns an `Error` type if an error occurs during signing. + * `basicNodeSigner` has been updated to reflect the new type. +- `ClientOptions.signAuthEntry` type has also been updated to reflect the SEP 43 protocol, which also returns a promise containing the`signerAddress` in addition to the `signAuthEntry` that was returned previously. It also can return an `Error` type. + ### Added - `stellartoml-Resolver.resolve` now has a `allowedRedirects` option to configure the number of allowed redirects to follow when resolving a stellar toml file. diff --git a/src/contract/assembled_transaction.ts b/src/contract/assembled_transaction.ts index 803f4f686..8e95c3d8d 100644 --- a/src/contract/assembled_transaction.ts +++ b/src/contract/assembled_transaction.ts @@ -16,6 +16,7 @@ import type { ClientOptions, MethodOptions, Tx, + WalletError, XDR_BASE64, } from "./types"; import { Server } from "../rpc"; @@ -324,6 +325,10 @@ export class AssembledTransaction { NotYetSimulated: class NotYetSimulatedError extends Error { }, FakeAccount: class FakeAccountError extends Error { }, SimulationFailed: class SimulationFailedError extends Error { }, + InternalWalletError: class InternalWalletError extends Error { }, + ExternalServiceError: class ExternalServiceError extends Error { }, + InvalidClientRequest: class InvalidClientRequestError extends Error { }, + UserRejected: class UserRejectedError extends Error { }, }; /** @@ -416,6 +421,26 @@ export class AssembledTransaction { return txn; } + private handleWalletError(error?: WalletError): void { + if (!error) return; + + const { message, code } = error; + const fullMessage = `${message}${error.ext ? ` (${ error.ext.join(', ') })` : ''}`; + + switch (code) { + case -1: + throw new AssembledTransaction.Errors.InternalWalletError(fullMessage); + case -2: + throw new AssembledTransaction.Errors.ExternalServiceError(fullMessage); + case -3: + throw new AssembledTransaction.Errors.InvalidClientRequest(fullMessage); + case -4: + throw new AssembledTransaction.Errors.UserRejected(fullMessage); + default: + throw new Error(`Unhandled error: ${fullMessage}`); + } + } + private constructor(public options: AssembledTransactionOptions) { this.options.simulate = this.options.simulate ?? true; this.server = new Server(this.options.rpcUrl, { @@ -683,13 +708,21 @@ export class AssembledTransaction { .setTimeout(timeoutInSeconds) .build(); - const signature = await signTransaction( + const signOpts: Parameters>[1] = { + networkPassphrase: this.options.networkPassphrase, + }; + + if (this.options.address) signOpts.address = this.options.address; + if (this.options.submit !== undefined) signOpts.submit = this.options.submit; + if (this.options.submitUrl) signOpts.submitUrl = this.options.submitUrl; + + const { signedTxXdr: signature, error } = await signTransaction( this.built.toXDR(), - { - networkPassphrase: this.options.networkPassphrase, - }, + signOpts, ); + this.handleWalletError(error); + this.signed = TransactionBuilder.fromXDR( signature, this.options.networkPassphrase, @@ -728,7 +761,20 @@ export class AssembledTransaction { signTransaction?: ClientOptions["signTransaction"]; } = {}): Promise> => { if(!this.signed){ - await this.sign({ force, signTransaction }); + // Store the original submit option + const originalSubmit = this.options.submit; + + // Temporarily disable submission in signTransaction to prevent double submission + if (this.options.submit) { + this.options.submit = false; + } + + try { + await this.sign({ force, signTransaction }); + } finally { + // Restore the original submit option + this.options.submit = originalSubmit; + } } return this.send(); }; @@ -841,7 +887,7 @@ export class AssembledTransaction { * If you have a pro use-case and need to override the default `authorizeEntry` function, rather than using the one in @stellar/stellar-base, you can do that! Your function needs to take at least the first argument, `entry: xdr.SorobanAuthorizationEntry`, and return a `Promise`. * * Note that you if you pass this, then `signAuthEntry` will be ignored. - */ + */ authorizeEntry?: typeof stellarBaseAuthorizeEntry; } = {}): Promise => { if (!this.built) @@ -898,13 +944,13 @@ export class AssembledTransaction { // eslint-disable-next-line no-await-in-loop authEntries[i] = await authorizeEntry( entry, - async (preimage) => - Buffer.from( - await sign(preimage.toXDR("base64"), { - accountToSign: address, - }), - "base64", - ), + async (preimage) => { + const { signedAuthEntry, error } = await sign(preimage.toXDR("base64"), { + address, + }); + this.handleWalletError(error); + return Buffer.from(signedAuthEntry, "base64"); + }, await expiration, // eslint-disable-line no-await-in-loop this.options.networkPassphrase, ); diff --git a/src/contract/basic_node_signer.ts b/src/contract/basic_node_signer.ts index bcd372a7e..849c10310 100644 --- a/src/contract/basic_node_signer.ts +++ b/src/contract/basic_node_signer.ts @@ -1,5 +1,6 @@ import { Keypair, TransactionBuilder, hash } from "@stellar/stellar-base"; import type { Client } from "./client"; +import { SignAuthEntry, SignTransaction } from "./types"; /** * For use with {@link Client} and {@link module:contract.AssembledTransaction}. @@ -16,14 +17,25 @@ import type { Client } from "./client"; export const basicNodeSigner = ( keypair: Keypair, networkPassphrase: string, -) => ({ +): { + signTransaction: SignTransaction; + signAuthEntry: SignAuthEntry; +} => ({ // eslint-disable-next-line require-await - signTransaction: async (tx: string) => { - const t = TransactionBuilder.fromXDR(tx, networkPassphrase); + signTransaction: async (xdr, opts) => { + const t = TransactionBuilder.fromXDR(xdr, opts?.networkPassphrase || networkPassphrase); t.sign(keypair); - return t.toXDR(); + return { + signedTxXdr: t.toXDR(), + signerAddress: keypair.publicKey(), + }; }, // eslint-disable-next-line require-await - signAuthEntry: async (entryXdr: string): Promise => - keypair.sign(hash(Buffer.from(entryXdr, "base64"))).toString("base64"), -}); + signAuthEntry: async (authEntry) => { + const signedAuthEntry = keypair.sign(hash(Buffer.from(authEntry, "base64"))).toString("base64"); + return { + signedAuthEntry, + signerAddress: keypair.publicKey(), + }; + }, +}); \ No newline at end of file diff --git a/src/contract/types.ts b/src/contract/types.ts index 754046839..707a6f8fe 100644 --- a/src/contract/types.ts +++ b/src/contract/types.ts @@ -55,6 +55,60 @@ export type Duration = bigint; */ export type Tx = Transaction, Operation[]>; +export interface WalletError { + message: string; // general description message returned to the client app + code: number; // unique error code + ext?: Array; // optional extended details +} + +/** + * A function to request a wallet to sign a built transaction + * + * This function takes an XDR provided by the requester and applies a signature to it. + * It returns a base64-encoded string XDR-encoded Transaction Envelope with Decorated Signatures + * and the signer address back to the requester. + * + * @param xdr - The XDR string representing the transaction to be signed. + * @param opts - Options for signing the transaction. + * @param opts.networkPassphrase - The network's passphrase on which the transaction is intended to be signed. + * @param opts.address - The public key of the account that should be used to sign. + * @param opts.submit - If set to true, submits the transaction immediately after signing. + * @param opts.submitUrl - The URL of the network to which the transaction should be submitted, if applicable. + * + * @returns A promise resolving to an object with the signed transaction XDR and optional signer address and error. + */ +export type SignTransaction = (xdr: string, opts?: { + networkPassphrase?: string; + address?: string; + submit?: boolean; + submitUrl?: string; +}) => Promise<{ + signedTxXdr: string; + signerAddress?: string; +} & { error?: WalletError }>; + +/** + * A function to request a wallet to sign an authorization entry preimage. + * + * Similar to signing a transaction, this function takes an authorization entry preimage provided by the + * requester and applies a signature to it. + * It returns a signed hash of the same authorization entry and the signer address back to the requester. + * + * @param authEntry - The authorization entry preimage to be signed. + * @param opts - Options for signing the authorization entry. + * @param opts.networkPassphrase - The network's passphrase on which the authorization entry is intended to be signed. + * @param opts.address - The public key of the account that should be used to sign. + * + * @returns A promise resolving to an object with the signed authorization entry and optional signer address and error. + */ +export type SignAuthEntry = (authEntry: string, opts?: { + networkPassphrase?: string; + address?: string; +}) => Promise<{ + signedAuthEntry: string; + signerAddress?: string; +} & { error?: WalletError }>; + /** * Options for a smart contract client. * @memberof module:contract @@ -77,14 +131,7 @@ export type ClientOptions = { * * Matches signature of `signTransaction` from Freighter. */ - signTransaction?: ( - tx: XDR_BASE64, - opts?: { - network?: string; - networkPassphrase?: string; - accountToSign?: string; - }, - ) => Promise; + signTransaction?: SignTransaction; /** * A function to sign a specific auth entry for a transaction, using the * private key corresponding to the provided `publicKey`. This is only needed @@ -94,12 +141,7 @@ export type ClientOptions = { * * Matches signature of `signAuthEntry` from Freighter. */ - signAuthEntry?: ( - entryXdr: XDR_BASE64, - opts?: { - accountToSign?: string; - }, - ) => Promise; + signAuthEntry?: SignAuthEntry; /** The address of the contract the client will interact with. */ contractId: string; /** @@ -176,6 +218,24 @@ export type AssembledTransactionOptions = MethodOptions & method: string; args?: any[]; parseResultXdr: (xdr: xdr.ScVal) => T; + + /** + * The address of the account that should sign the transaction. Useful when + * a wallet holds multiple addresses to ensure signing with the intended one. + */ + address?: string; + + /** + * This option will be passed through to the SEP43-compatible wallet extension. If true, and if the wallet supports it, the transaction will be signed and immediately submitted to the network by the wallet, bypassing the submit logic in {@link SentTransaction}. + * @default false + */ + submit?: boolean; + + /** + * The URL of the network to which the transaction should be submitted. + * Only applicable when 'submit' is set to true. + */ + submitUrl?: string; }; /**