From 8f9360fa43367f6c52e245b2126feac2a4c1d20d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A1ssio=20Marcos=20Goulart?= <3228151+CassioMG@users.noreply.github.com> Date: Thu, 27 Jun 2024 17:47:03 -0300 Subject: [PATCH] [TS] Implement support for SEP-7 (#141) * Implement support for Sep-7 * Add a couple missing exceptions * Assign a few missing types * Add JSDocs for Sep7 classes * Add JSDocs for sep7Parser file * Make isValidSep7Uri also return a "reason" message for failed verifications * Augment tests to account for error reasons strings * Comment tweak --- @stellar/typescript-wallet-sdk/src/index.ts | 9 + .../src/walletSdk/Auth/WalletSigner.ts | 6 +- .../src/walletSdk/Customer/index.ts | 8 +- .../src/walletSdk/Exceptions/index.ts | 39 +- .../src/walletSdk/Horizon/Account.ts | 3 +- .../src/walletSdk/Types/index.ts | 1 + .../src/walletSdk/Types/sep7.ts | 19 + .../src/walletSdk/Uri/Sep7Base.ts | 311 +++++++ .../src/walletSdk/Uri/Sep7Pay.ts | 169 ++++ .../src/walletSdk/Uri/Sep7Tx.ts | 193 ++++ .../src/walletSdk/Uri/index.ts | 9 + .../src/walletSdk/Uri/sep7Parser.ts | 222 +++++ .../src/walletSdk/Utils/index.ts | 3 +- .../src/walletSdk/Watcher/index.ts | 4 +- .../typescript-wallet-sdk/test/sep7.test.ts | 829 ++++++++++++++++++ 15 files changed, 1814 insertions(+), 11 deletions(-) create mode 100644 @stellar/typescript-wallet-sdk/src/walletSdk/Types/sep7.ts create mode 100644 @stellar/typescript-wallet-sdk/src/walletSdk/Uri/Sep7Base.ts create mode 100644 @stellar/typescript-wallet-sdk/src/walletSdk/Uri/Sep7Pay.ts create mode 100644 @stellar/typescript-wallet-sdk/src/walletSdk/Uri/Sep7Tx.ts create mode 100644 @stellar/typescript-wallet-sdk/src/walletSdk/Uri/index.ts create mode 100644 @stellar/typescript-wallet-sdk/src/walletSdk/Uri/sep7Parser.ts create mode 100644 @stellar/typescript-wallet-sdk/test/sep7.test.ts diff --git a/@stellar/typescript-wallet-sdk/src/index.ts b/@stellar/typescript-wallet-sdk/src/index.ts index cb6050b..109ca8c 100644 --- a/@stellar/typescript-wallet-sdk/src/index.ts +++ b/@stellar/typescript-wallet-sdk/src/index.ts @@ -33,6 +33,15 @@ export { SponsoringBuilder, } from "./walletSdk/Horizon"; export { Recovery } from "./walletSdk/Recovery"; +export { + Sep7Base, + Sep7Pay, + Sep7Tx, + isValidSep7Uri, + parseSep7Uri, + sep7ReplacementsFromString, + sep7ReplacementsToString, +} from "./walletSdk/Uri"; export { Watcher } from "./walletSdk/Watcher"; /** diff --git a/@stellar/typescript-wallet-sdk/src/walletSdk/Auth/WalletSigner.ts b/@stellar/typescript-wallet-sdk/src/walletSdk/Auth/WalletSigner.ts index f454bb8..75ef438 100644 --- a/@stellar/typescript-wallet-sdk/src/walletSdk/Auth/WalletSigner.ts +++ b/@stellar/typescript-wallet-sdk/src/walletSdk/Auth/WalletSigner.ts @@ -10,6 +10,7 @@ import { HttpHeaders, } from "../Types"; import { DefaultClient } from "../"; +import { DefaultSignerDomainAccountError } from "../Exceptions"; /** * A Wallet Signer for signing Stellar transactions. @@ -51,9 +52,8 @@ export const DefaultSigner: WalletSigner = { }, // eslint-disable-next-line @typescript-eslint/require-await signWithDomainAccount: async () => { - throw new Error( - "The DefaultSigner can't sign transactions with domain account", - ); + // The DefaultSigner can't sign transactions with domain account + throw new DefaultSignerDomainAccountError(); }, }; diff --git a/@stellar/typescript-wallet-sdk/src/walletSdk/Customer/index.ts b/@stellar/typescript-wallet-sdk/src/walletSdk/Customer/index.ts index db36a3e..bdc23ce 100644 --- a/@stellar/typescript-wallet-sdk/src/walletSdk/Customer/index.ts +++ b/@stellar/typescript-wallet-sdk/src/walletSdk/Customer/index.ts @@ -17,10 +17,10 @@ import { * @class */ export class Sep12 { - private authToken; - private baseUrl; - private httpClient; - private headers; + private authToken: AuthToken; + private baseUrl: string; + private httpClient: AxiosInstance; + private headers: { [key: string]: string }; /** * Creates a new instance of the Sep12 class. diff --git a/@stellar/typescript-wallet-sdk/src/walletSdk/Exceptions/index.ts b/@stellar/typescript-wallet-sdk/src/walletSdk/Exceptions/index.ts index eb8aceb..6839108 100644 --- a/@stellar/typescript-wallet-sdk/src/walletSdk/Exceptions/index.ts +++ b/@stellar/typescript-wallet-sdk/src/walletSdk/Exceptions/index.ts @@ -306,6 +306,20 @@ export class InvalidJsonError extends Error { } } +export class SigningKeypairMissingSecretError extends Error { + constructor() { + super("This keypair doesn't have a secret key and can't sign"); + Object.setPrototypeOf(this, SigningKeypairMissingSecretError.prototype); + } +} + +export class DefaultSignerDomainAccountError extends Error { + constructor() { + super("The DefaultSigner can't sign transactions with domain account"); + Object.setPrototypeOf(this, DefaultSignerDomainAccountError.prototype); + } +} + export class AuthHeaderSigningKeypairRequiredError extends Error { constructor() { super("Must be SigningKeypair to sign auth header"); @@ -319,8 +333,31 @@ export class AuthHeaderSigningKeypairRequiredError extends Error { export class AuthHeaderClientDomainRequiredError extends Error { constructor() { super( - "This class should only be used for remote signing. For local signing use DefaultAuthHeaderSigner.", + "This class should only be used for remote signing. For local signing use DefaultAuthHeaderSigner", ); Object.setPrototypeOf(this, AuthHeaderClientDomainRequiredError.prototype); } } + +export class Sep7InvalidUriError extends Error { + constructor(reason: string) { + super(`Invalid Stellar Sep-7 URI, reason: ${reason}`); + Object.setPrototypeOf(this, Sep7InvalidUriError.prototype); + } +} + +export class Sep7LongMsgError extends Error { + constructor(msgMaxLength: number) { + super(`'msg' should be no longer than ${msgMaxLength} characters`); + Object.setPrototypeOf(this, Sep7LongMsgError.prototype); + } +} + +export class Sep7UriTypeNotSupportedError extends Error { + constructor(type: string) { + super( + `Stellar Sep-7 URI operation type '${type}' is not currently supported`, + ); + Object.setPrototypeOf(this, Sep7UriTypeNotSupportedError.prototype); + } +} diff --git a/@stellar/typescript-wallet-sdk/src/walletSdk/Horizon/Account.ts b/@stellar/typescript-wallet-sdk/src/walletSdk/Horizon/Account.ts index 24e4cdc..0921b75 100644 --- a/@stellar/typescript-wallet-sdk/src/walletSdk/Horizon/Account.ts +++ b/@stellar/typescript-wallet-sdk/src/walletSdk/Horizon/Account.ts @@ -1,4 +1,5 @@ import { Keypair, Transaction, FeeBumpTransaction } from "@stellar/stellar-sdk"; +import { SigningKeypairMissingSecretError } from "../Exceptions"; export class AccountKeypair { keypair: Keypair; @@ -28,7 +29,7 @@ export class PublicKeypair extends AccountKeypair { export class SigningKeypair extends AccountKeypair { constructor(keypair: Keypair) { if (!keypair.canSign()) { - throw new Error("This keypair doesn't have a secret key and can't sign"); + throw new SigningKeypairMissingSecretError(); } super(keypair); } diff --git a/@stellar/typescript-wallet-sdk/src/walletSdk/Types/index.ts b/@stellar/typescript-wallet-sdk/src/walletSdk/Types/index.ts index bb6fd43..c589f31 100644 --- a/@stellar/typescript-wallet-sdk/src/walletSdk/Types/index.ts +++ b/@stellar/typescript-wallet-sdk/src/walletSdk/Types/index.ts @@ -46,6 +46,7 @@ export * from "./auth"; export * from "./horizon"; export * from "./recovery"; export * from "./sep6"; +export * from "./sep7"; export * from "./sep12"; export * from "./sep24"; export * from "./sep38"; diff --git a/@stellar/typescript-wallet-sdk/src/walletSdk/Types/sep7.ts b/@stellar/typescript-wallet-sdk/src/walletSdk/Types/sep7.ts new file mode 100644 index 0000000..4ba84e8 --- /dev/null +++ b/@stellar/typescript-wallet-sdk/src/walletSdk/Types/sep7.ts @@ -0,0 +1,19 @@ +export const WEB_STELLAR_SCHEME = "web+stellar:"; + +export enum Sep7OperationType { + tx = "tx", + pay = "pay", +} + +export const URI_MSG_MAX_LENGTH = 300; + +export type Sep7Replacement = { + id: string; + path: string; + hint: string; +}; + +export type IsValidSep7UriResult = { + result: boolean; + reason?: string; +}; diff --git a/@stellar/typescript-wallet-sdk/src/walletSdk/Uri/Sep7Base.ts b/@stellar/typescript-wallet-sdk/src/walletSdk/Uri/Sep7Base.ts new file mode 100644 index 0000000..26b694f --- /dev/null +++ b/@stellar/typescript-wallet-sdk/src/walletSdk/Uri/Sep7Base.ts @@ -0,0 +1,311 @@ +import { Keypair, Networks, StellarToml } from "@stellar/stellar-sdk"; +import { Sep7OperationType, URI_MSG_MAX_LENGTH } from "../Types"; +import { Sep7LongMsgError } from "../Exceptions"; + +/** + * A base abstract class containing common functions that should be used by both + * Sep7Tx and Sep7Pay classes for parsing or constructing SEP-0007 Stellar URIs. + * + * @see https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0007.md#specification + */ +export abstract class Sep7Base { + protected uri: URL; + + /** + * Creates a new instance of the Sep7 class. + * + * @constructor + * @param {URL | string} uri - uri to initialize the Sep7 instance. + */ + constructor(uri: URL | string) { + this.uri = new URL(uri.toString()); + + if (this.msg?.length > URI_MSG_MAX_LENGTH) { + throw new Sep7LongMsgError(URI_MSG_MAX_LENGTH); + } + } + + /** + * Should return a deep clone of this instance. + * + * @returns {Sep7Base} a deep clone of the Sep7Base extended instance. + */ + abstract clone(): Sep7Base; + + /** + * Returns a stringfied URL-decoded version of the 'uri' object. + * + * @returns {string} the uri decoded string value. + */ + toString(): string { + return this.uri.toString(); + } + + /** + * Returns uri's pathname as the operation type. + * + * @returns {Sep7OperationType} the operation type, either "tx" or "pay". + */ + get operationType(): Sep7OperationType { + return this.uri.pathname as Sep7OperationType; + } + + /** + * Returns a URL-decoded version of the uri 'callback' param without + * the 'url:' prefix. + * + * The URI handler should send the signed XDR to this callback url, if this + * value is omitted then the URI handler should submit it to the network. + * + * @returns {string | undefined} URL-decoded 'callback' param if present. + */ + get callback(): string | undefined { + const callback = this.getParam("callback"); + + if (callback?.startsWith("url:")) { + return callback.replace("url:", ""); + } + + return callback; + } + + /** + * Sets and URL-encodes the uri 'callback' param, appends the 'url:' + * prefix to it if not yet present. + * + * Deletes the uri 'callback' param if set as 'undefined'. + * + * The URI handler should send the signed XDR to this callback url, if this + * value is omitted then the URI handler should submit it to the network. + * + * @param {string | undefined} callback the uri 'callback' param to be set. + */ + set callback(callback: string | undefined) { + if (!callback) { + this.setParam("callback", undefined); + return; + } + + if (callback.startsWith("url:")) { + this.setParam("callback", callback); + return; + } + + this.setParam("callback", `url:${callback}`); + } + + /** + * Returns a URL-decoded version of the uri 'msg' param. + * + * This message should indicate any additional information that the website + * or application wants to show the user in her wallet. + * + * @returns {string | undefined} URL-decoded 'msg' param if present. + */ + get msg(): string | undefined { + return this.getParam("msg"); + } + + /** + * Sets and URL-encodes the uri 'msg' param, the 'msg' param can't + * be larger than 300 characters. + * + * Deletes the uri 'msg' param if set as 'undefined'. + * + * This message should indicate any additional information that the website + * or application wants to show the user in her wallet. + * + * @param {string | undefined} msg the uri 'msg' param to be set. + * @throws {Sep7LongMsgError} if 'msg' length is bigger than 300. + */ + set msg(msg: string | undefined) { + if (msg?.length > URI_MSG_MAX_LENGTH) { + throw new Sep7LongMsgError(URI_MSG_MAX_LENGTH); + } + + this.setParam("msg", msg); + } + + /** + * Returns uri 'network_passphrase' param, if not present returns + * the PUBLIC Network value by default: 'Public Global Stellar Network ; September 2015'. + * + * @returns {Networks} the Stellar network passphrase considered for this uri. + */ + get networkPassphrase(): Networks { + return (this.getParam("network_passphrase") ?? Networks.PUBLIC) as Networks; + } + + /** + * Sets the uri 'network_passphrase' param. + * + * Deletes the uri 'network_passphrase' param if set as 'undefined'. + * + * Only need to set it if this transaction is for a network other than + * the public network. + * + * @param {Networks | undefined} networkPassphrase the uri 'network_passphrase' + * param to be set. + */ + set networkPassphrase(networkPassphrase: Networks | undefined) { + this.setParam("network_passphrase", networkPassphrase); + } + + /** + * Returns a URL-decoded version of the uri 'origin_domain' param. + * + * This should be a fully qualified domain name that specifies the originating + * domain of the URI request. + * + * @returns {string | undefined} URL-decoded 'origin_domain' param if present. + */ + get originDomain(): string | undefined { + return this.getParam("origin_domain"); + } + + /** + * Sets and URL-encodes the uri 'origin_domain' param. + * + * Deletes the uri 'origin_domain' param if set as 'undefined'. + * + * This should be a fully qualified domain name that specifies the originating + * domain of the URI request. + * + * @param {string | undefined} originDomain the uri 'origin_domain' param + * to be set. + */ + set originDomain(originDomain: string | undefined) { + this.setParam("origin_domain", originDomain); + } + + /** + * Returns a URL-decoded version of the uri 'signature' param. + * + * This should be a signature of the hash of the URI request (excluding the + * 'signature' field and value itself). + * + * Wallets should use the URI_REQUEST_SIGNING_KEY specified in the + * origin_domain's stellar.toml file to validate this signature. + * If the verification fails, wallets must alert the user. + * + * @returns {string | undefined} URL-decoded 'signature' param if present. + */ + get signature(): string | undefined { + return this.getParam("signature"); + } + + /** + * Signs the URI with the given keypair, which means it sets the 'signature' param. + * + * This should be the last step done before generating the URI string, + * otherwise the signature will be invalid for the URI. + * + * @see https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0007.md#request-signing + * + * @param {Keypair} keypair The keypair (including secret key), used to sign the request. + * This should be the keypair found in the URI_REQUEST_SIGNING_KEY field of the + * origin_domains' stellar.toml. + * + * @returns {string} the generated 'signature' param. + */ + addSignature(keypair: Keypair): string { + const payload = this.createSignaturePayload(); + const signature = keypair.sign(payload).toString("base64"); + this.setParam("signature", signature); + return signature; + } + + /** + * Verifies that the signature added to the URI is valid. + * + * @see https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0007.md#request-signing + * + * @returns {Promise} returns 'true' if the signature is valid for + * the current URI and origin_domain. Returns 'false' if signature verification + * fails, or if there is a problem looking up the stellar.toml associated with + * the origin_domain. + */ + async verifySignature(): Promise { + const originDomain = this.originDomain; + const signature = this.signature; + + // we can fail fast if neither of them are set since we can't verify without both + if (!originDomain || !signature) { + return false; + } + + try { + const toml = await StellarToml.Resolver.resolve(originDomain); + const signingKey = toml.URI_REQUEST_SIGNING_KEY; + + if (!signingKey) { + return false; + } + const keypair = Keypair.fromPublicKey(signingKey); + const payload = this.createSignaturePayload(); + return keypair.verify(payload, Buffer.from(signature, "base64")); + } catch (e) { + // if something fails we assume signature verification failed + return false; + } + } + + /** + * Finds the uri param related to the inputted 'key', if any, and returns + * a URL-decoded version of it. Returns 'undefined' if key param not found. + * + * @param {string} key the uri param key. + * + * @returns {string | undefined} URL-decoded value of the uri param if found. + */ + protected getParam(key: string): string | undefined { + // the searchParams.get() function automatically applies URL dencoding. + return this.uri.searchParams.get(key) || undefined; + } + + /** + * Sets and URL-encodes a 'key=value' uri param. + * + * Deletes the uri param if 'value' set as 'undefined'. + * + * @param {string} key the uri param key. + * @param {string | undefined} value the uri param value to be set. + */ + protected setParam(key: string, value: string | undefined) { + if (!value) { + this.uri.searchParams.delete(key); + return; + } + + // the searchParams.set() function automatically applies URL encoding. + this.uri.searchParams.set(key, value); + } + + /** + * Converts the URI request into the payload that will be signed by + * the 'addSignature' method. + * + * @see https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0007.md#request-signing + * + * @returns {Buffer} array of bytes to be signed with given keypair on + * the 'addSignature' method. + */ + private createSignaturePayload(): Buffer { + let data = this.toString(); + + const signature = this.signature; + if (signature) { + // the payload must be created without the signature on it + data = data.replace(`&signature=${encodeURIComponent(signature)}`, ""); + } + + // The first 35 bytes of the payload are all 0, the 36th byte is 4. + // Then we concatenate the URI request with the prefix 'stellar.sep.7 - URI Scheme' + // (no delimiter) and convert that to bytes to give use the final payload to be signed. + return Buffer.concat([ + Buffer.alloc(35, 0), + Buffer.alloc(1, 4), + Buffer.from(`stellar.sep.7 - URI Scheme${data}`), + ]); + } +} diff --git a/@stellar/typescript-wallet-sdk/src/walletSdk/Uri/Sep7Pay.ts b/@stellar/typescript-wallet-sdk/src/walletSdk/Uri/Sep7Pay.ts new file mode 100644 index 0000000..43ea2a7 --- /dev/null +++ b/@stellar/typescript-wallet-sdk/src/walletSdk/Uri/Sep7Pay.ts @@ -0,0 +1,169 @@ +import { MemoType } from "@stellar/stellar-sdk"; +import { Sep7Base } from "../Uri"; +import { Sep7OperationType, WEB_STELLAR_SCHEME } from "../Types"; + +/** + * The Sep-7 'pay' operation represents a request to pay a specific address + * with a specific asset, regardless of the source asset used by the payer. + * + * @see https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0007.md#operation-pay + */ +export class Sep7Pay extends Sep7Base { + /** + * Creates a Sep7Pay instance with given destination. + * + * @param {string} destination a valid Stellar address to receive the payment. + * + * @returns {Sep7Pay} the Sep7Pay instance. + */ + static forDestination(destination: string): Sep7Pay { + const uri = new Sep7Pay(); + uri.destination = destination; + return uri; + } + + /** + * Creates a new instance of the Sep7Pay class. + * + * @constructor + * @param {URL | string} [uri] - uri to initialize the Sep7 instance. + */ + constructor(uri?: URL | string) { + super(uri ?? new URL(`${WEB_STELLAR_SCHEME}${Sep7OperationType.pay}`)); + } + + /** + * Returns a deep clone of this instance. + * + * @returns {Sep7Pay} a deep clone of this Sep7Pay instance. + */ + clone(): Sep7Pay { + return new Sep7Pay(this.uri); + } + + /** + * Gets the destination of the payment request, which should be a valid + * Stellar address. + * + * @returns {string | undefined} the 'destination' uri param if present. + */ + get destination(): string | undefined { + return this.getParam("destination"); + } + + /** + * Sets the destination of the payment request, which should be a valid + * Stellar address. + * + * Deletes the uri 'destination' param if set as 'undefined'. + * + * @param {string | undefined} destination the uri 'destination' param to be set. + */ + set destination(destination: string | undefined) { + this.setParam("destination", destination); + } + + /** + * Gets the amount that destination should receive. + * + * @returns {string | undefined} the 'amount' uri param if present. + */ + get amount(): string | undefined { + return this.getParam("amount"); + } + + /** + * Sets the amount that destination should receive. + * + * Deletes the uri 'amount' param if set as 'undefined'. + * + * @param {string | undefined} amount the uri 'amount' param to be set. + */ + set amount(amount: string | undefined) { + this.setParam("amount", amount); + } + + /** + * Gets the code from the asset that destination should receive. + * + * @returns {string | undefined} the 'asset_code' uri param if present. + */ + get assetCode(): string | undefined { + return this.getParam("asset_code"); + } + + /** + * Sets the code from the asset that destination should receive. + * + * Deletes the uri 'asset_code' param if set as 'undefined'. + * + * @param {string | undefined} assetCode the uri 'asset_code' param to be set. + */ + set assetCode(assetCode: string | undefined) { + this.setParam("asset_code", assetCode); + } + + /** + * Gets the account ID of asset issuer the destination should receive. + * + * @returns {string | undefined} the 'asset_issuer' uri param if present. + */ + get assetIssuer(): string | undefined { + return this.getParam("asset_issuer"); + } + + /** + * Sets the account ID of asset issuer the destination should receive. + * + * Deletes the uri 'asset_issuer' param if set as 'undefined'. + * + * @param {string | undefined} assetIssuer the uri 'asset_issuer' param to be set. + */ + set assetIssuer(assetIssuer: string | undefined) { + this.setParam("asset_issuer", assetIssuer); + } + + /** + * Gets the memo to be included in the payment / path payment. + * Memos of type MEMO_HASH and MEMO_RETURN should be base64-decoded + * after returned from this function. + * + * @returns {string | undefined} the 'memo' uri param if present. + */ + get memo(): string | undefined { + return this.getParam("memo"); + } + + /** + * Sets the memo to be included in the payment / path payment. + * Memos of type MEMO_HASH and MEMO_RETURN should be base64-encoded + * prior to being passed on this function. + * + * Deletes the uri 'memo' param if set as 'undefined'. + * + * @param {string | undefined} memo the uri 'memo' param to be set. + */ + set memo(memo: string | undefined) { + this.setParam("memo", memo); + } + + /** + * Gets the type of the memo. + * + * @returns {MemoType | undefined} the 'memo_type' uri param if present. + */ + get memoType(): MemoType | undefined { + return this.getParam("memo_type") as MemoType | undefined; + } + + /** + * Sets the type of the memo. + * + * Deletes the uri 'memo_type' param if set as 'undefined'. + * + * @param {MemoType | undefined} memoType the uri 'memo_type' param to be set. + */ + set memoType(memoType: MemoType | undefined) { + this.setParam("memo_type", memoType); + } +} diff --git a/@stellar/typescript-wallet-sdk/src/walletSdk/Uri/Sep7Tx.ts b/@stellar/typescript-wallet-sdk/src/walletSdk/Uri/Sep7Tx.ts new file mode 100644 index 0000000..b2b042f --- /dev/null +++ b/@stellar/typescript-wallet-sdk/src/walletSdk/Uri/Sep7Tx.ts @@ -0,0 +1,193 @@ +import { Networks, Transaction } from "@stellar/stellar-sdk"; +import { + Sep7Base, + sep7ReplacementsFromString, + sep7ReplacementsToString, +} from "../Uri"; +import { + Sep7OperationType, + Sep7Replacement, + WEB_STELLAR_SCHEME, +} from "../Types"; + +/** + * The Sep-7 'tx' operation represents a request to sign + * a specific XDR TransactionEnvelope. + * + * @see https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0007.md#operation-tx + */ +export class Sep7Tx extends Sep7Base { + /** + * Creates a Sep7Tx instance with given transaction. + * + * Sets the 'xdr' param as a Stellar TransactionEnvelope in XDR format that + * is base64 encoded and then URL-encoded. + * + * @param {Transaction} transaction a transaction which will be used to set the + * URI 'xdr' and 'network_passphrase' query params. + * + * @returns {Sep7Tx} the Sep7Tx instance. + */ + static forTransaction(transaction: Transaction): Sep7Tx { + const uri = new Sep7Tx(); + uri.xdr = transaction.toEnvelope().toXDR().toString("base64"); + uri.networkPassphrase = transaction.networkPassphrase as Networks; + return uri; + } + + /** + * Creates a new instance of the Sep7Tx class. + * + * @constructor + * @param {URL | string} [uri] - uri to initialize the Sep7 instance. + */ + constructor(uri?: URL | string) { + super(uri ?? new URL(`${WEB_STELLAR_SCHEME}${Sep7OperationType.tx}`)); + } + + /** + * Returns a deep clone of this instance. + * + * @returns {Sep7Tx} a deep clone of this Sep7Tx instance. + */ + clone(): Sep7Tx { + return new Sep7Tx(this.uri); + } + + /** + * Returns a URL-decoded version of the uri 'xdr' param. + * + * @returns {string | undefined} URL-decoded 'xdr' param if present. + */ + get xdr(): string | undefined { + return this.getParam("xdr"); + } + + /** + * Sets and URL-encodes the uri 'xdr' param. + * + * Deletes the uri 'xdr' param if set as 'undefined'. + * + * @param {string | undefined} xdr the uri 'xdr' param to be set. + */ + set xdr(xdr: string | undefined) { + this.setParam("xdr", xdr); + } + + /** + * Returns the uri 'pubkey' param. + * + * This param specifies which public key the URI handler should sign for. + * + * @returns {string | undefined} URL-decoded 'pubkey' param if present. + */ + get pubkey(): string | undefined { + return this.getParam("pubkey"); + } + + /** + * Sets the uri 'pubkey' param. + * + * Deletes the uri 'pubkey' param if set as 'undefined'. + * + * This param should specify which public key you want the URI handler + * to sign for. + * + * @param {string | undefined} pubkey the uri 'pubkey' param to be set. + */ + set pubkey(pubkey: string | undefined) { + this.setParam("pubkey", pubkey); + } + + /** + * Returns a URL-decoded version of the uri 'chain' param. + * + * There can be an optional chain query param to include a single SEP-0007 + * request that spawned or triggered the creation of this SEP-0007 request. + * This will be a URL-encoded value. The goal of this field is to be + * informational only and can be used to forward SEP-0007 requests. + * + * @returns {string | undefined} URL-decoded 'chain' param if present. + */ + get chain(): string | undefined { + return this.getParam("chain"); + } + + /** + * Sets and URL-encodes the uri 'chain' param. + * + * Deletes the uri 'chain' param if set as 'undefined'. + * + * There can be an optional chain query param to include a single SEP-0007 + * request that spawned or triggered the creation of this SEP-0007 request. + * This will be a URL-encoded value. The goal of this field is to be + * informational only and can be used to forward SEP-0007 requests. + * + * @param {string | undefined} chain the 'chain' param to be set. + */ + set chain(chain: string | undefined) { + this.setParam("chain", chain); + } + + /** + * Gets a list of fields in the transaction that need to be replaced. + * + * @returns {Sep7Replacement[]} list of fields that need to be replaced. + */ + getReplacements(): Sep7Replacement[] { + return sep7ReplacementsFromString(this.getParam("replace")); + } + + /** + * Sets and URL-encodes the uri 'replace' param, which is a list of fields in + * the transaction that needs to be replaced. + * + * Deletes the uri 'replace' param if set as empty array '[]' or 'undefined'. + * + * This 'replace' param should be a URL-encoded value that identifies the + * fields to be replaced in the XDR using the 'Txrep (SEP-0011)' representation. + * This will be specified in the format of: + * txrep_tx_field_name_1:reference_identifier_1,txrep_tx_field_name_2:reference_identifier_2;reference_identifier_1:hint_1,reference_identifier_2:hint_2 + * + * @see https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0011.md + * + * @param {Sep7Replacement[]} replacements a list of replacements to set. + */ + setReplacements(replacements: Sep7Replacement[] | undefined) { + if (!replacements || replacements.length === 0) { + this.setParam("replace", undefined); + return; + } + this.setParam("replace", sep7ReplacementsToString(replacements)); + } + + /** + * Adds an additional replacement. + * + * @param {Sep7Replacement} replacement the replacement to add. + */ + addReplacement(replacement: Sep7Replacement) { + const replacements = this.getReplacements(); + replacements.push(replacement); + this.setReplacements(replacements); + } + + /** + * Removes all replacements with the given identifier. + * + * @param {string} id the identifier to remove. + */ + removeReplacement(id: string) { + const replacements = this.getReplacements().filter((r) => r.id !== id); + this.setReplacements(replacements); + } + + /** + * Creates a Stellar Transaction from the URI's XDR and networkPassphrase. + * + * @returns {Transaction} the Stellar Transaction. + */ + getTransaction(): Transaction { + return new Transaction(this.xdr, this.networkPassphrase || Networks.PUBLIC); + } +} diff --git a/@stellar/typescript-wallet-sdk/src/walletSdk/Uri/index.ts b/@stellar/typescript-wallet-sdk/src/walletSdk/Uri/index.ts new file mode 100644 index 0000000..d66f39e --- /dev/null +++ b/@stellar/typescript-wallet-sdk/src/walletSdk/Uri/index.ts @@ -0,0 +1,9 @@ +export { Sep7Base } from "./Sep7Base"; +export { Sep7Pay } from "./Sep7Pay"; +export { Sep7Tx } from "./Sep7Tx"; +export { + isValidSep7Uri, + parseSep7Uri, + sep7ReplacementsFromString, + sep7ReplacementsToString, +} from "./sep7Parser"; diff --git a/@stellar/typescript-wallet-sdk/src/walletSdk/Uri/sep7Parser.ts b/@stellar/typescript-wallet-sdk/src/walletSdk/Uri/sep7Parser.ts new file mode 100644 index 0000000..7437d43 --- /dev/null +++ b/@stellar/typescript-wallet-sdk/src/walletSdk/Uri/sep7Parser.ts @@ -0,0 +1,222 @@ +import { Networks, StrKey, Transaction } from "@stellar/stellar-sdk"; + +import { Sep7Pay, Sep7Tx } from "../Uri"; +import { + Sep7Replacement, + Sep7OperationType, + IsValidSep7UriResult, + WEB_STELLAR_SCHEME, + URI_MSG_MAX_LENGTH, +} from "../Types"; +import { + Sep7InvalidUriError, + Sep7UriTypeNotSupportedError, +} from "../Exceptions"; + +/** + * Returns true if the given URI is a SEP-7 compliant URI, false otherwise. + * + * Currently this checks whether it starts with 'web+stellar:tx' or 'web+stellar:pay' + * and has its required parameters: 'xdr=' and 'destination=' respectively. + * + * @param {string} uri The URI string to check. + * + * @returns {IsValidSep7UriResult} returns '{ result: true }' if it's a valid Sep-7 + * uri, returns '{ result: false, reason: "" }' containing a 'reason' message + * in case the verification fails. + */ +export const isValidSep7Uri = (uri: string): IsValidSep7UriResult => { + if (!uri.startsWith(WEB_STELLAR_SCHEME)) { + return { + result: false, + reason: `it must start with '${WEB_STELLAR_SCHEME}'`, + }; + } + + const url = new URL(uri); + + const type = url.pathname as Sep7OperationType; + const xdr = url.searchParams.get("xdr"); + const networkPassphrase = + url.searchParams.get("network_passphrase") || Networks.PUBLIC; + const destination = url.searchParams.get("destination"); + const msg = url.searchParams.get("msg"); + + if (![Sep7OperationType.tx, Sep7OperationType.pay].includes(type)) { + return { + result: false, + reason: `operation type '${type}' is not currently supported`, + }; + } + + if (type === Sep7OperationType.tx && !xdr) { + return { + result: false, + reason: `operation type '${type}' must have a 'xdr' parameter`, + }; + } + + if (type === Sep7OperationType.tx && xdr) { + try { + new Transaction(xdr, networkPassphrase); + } catch { + return { + result: false, + reason: `the provided 'xdr' parameter is not a valid transaction envelope on the '${networkPassphrase}' network`, + }; + } + } + + if (type === Sep7OperationType.pay && !destination) { + return { + result: false, + reason: `operation type '${type}' must have a 'destination' parameter`, + }; + } + + if (type === Sep7OperationType.pay && destination) { + // Checks if it's a valid "G" or "M" Stellar address + // TODO: also check if it's a valid "C" address once the "@stellar/stellar-sdk" + // package is updated to version >= "v12.0.1". + const isValidStellarAddress = + StrKey.isValidEd25519PublicKey(destination) || + StrKey.isValidMed25519PublicKey(destination); + // StrKey.isValidContract(destination) => checks if it's a valid "C" address + + if (!isValidStellarAddress) { + return { + result: false, + reason: + "the provided 'destination' parameter is not a valid Stellar address", + }; + } + } + + if (msg?.length > URI_MSG_MAX_LENGTH) { + return { + result: false, + reason: `the 'msg' parameter should be no longer than ${URI_MSG_MAX_LENGTH} characters`, + }; + } + + return { + result: true, + }; +}; + +/** + * Try parsing a SEP-7 URI string and returns a Sep7Tx or Sep7Pay instance, + * depending on the type. + * + * @param {string} uri The URI string to parse. + * + * @returns {Sep7Tx | Sep7Pay} a uri parsed Sep7Tx or Sep7Pay instance. + * + * @throws {Sep7InvalidUriError} if the inputted uri is not a valid SEP-7 URI. + * @throws {Sep7UriTypeNotSupportedError} if the inputted uri does not have a + * supported SEP-7 type. + */ +export const parseSep7Uri = (uri: string): Sep7Tx | Sep7Pay => { + const isValid = isValidSep7Uri(uri); + if (!isValid.result) { + throw new Sep7InvalidUriError(isValid.reason); + } + + const url = new URL(uri); + + const type = url.pathname; + switch (type) { + case Sep7OperationType.tx: + return new Sep7Tx(url); + case Sep7OperationType.pay: + return new Sep7Pay(url); + default: + throw new Sep7UriTypeNotSupportedError(type); + } +}; + +/** + * String delimiters shared by the parsing functions below. + */ +const HINT_DELIMITER = ";"; +const ID_DELIMITER = ":"; +const LIST_DELIMITER = ","; + +/** + * Takes a Sep-7 URL-decoded 'replace' string param and parses it to a list of + * Sep7Replacement objects for easy of use. + * + * This string identifies the fields to be replaced in the XDR using + * the 'Txrep (SEP-0011)' representation, which should be specified in the format of: + * txrep_tx_field_name_1:reference_identifier_1,txrep_tx_field_name_2:reference_identifier_2;reference_identifier_1:hint_1,reference_identifier_2:hint_2 + * + * @see https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0011.md + * + * @param {string} [replacements] a replacements string in the + * 'Txrep (SEP-0011)' representation. + * + * @returns {Sep7Replacement[]} a list of parsed Sep7Replacement objects. + */ +export const sep7ReplacementsFromString = ( + replacements?: string, +): Sep7Replacement[] => { + if (!replacements) { + return []; + } + + const [txrepString, hintsString] = replacements.split(HINT_DELIMITER); + const hintsList = hintsString.split(LIST_DELIMITER); + + const hintsMap: { [id: string]: string } = {}; + + hintsList + .map((item) => item.split(ID_DELIMITER)) + .forEach(([id, hint]) => (hintsMap[id] = hint)); + + const txrepList = txrepString.split(LIST_DELIMITER); + + const replacementsList = txrepList + .map((item) => item.split(ID_DELIMITER)) + .map(([path, id]) => ({ id, path, hint: hintsMap[id] })); + + return replacementsList; +}; + +/** + * Takes a list of Sep7Replacement objects and parses it to a string that + * could be URL-encoded and used as a Sep-7 URI 'replace' param. + * + * This string identifies the fields to be replaced in the XDR using + * the 'Txrep (SEP-0011)' representation, which should be specified in the format of: + * txrep_tx_field_name_1:reference_identifier_1,txrep_tx_field_name_2:reference_identifier_2;reference_identifier_1:hint_1,reference_identifier_2:hint_2 + * + * @see https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0011.md + * + * @param {Sep7Replacement[]} [replacements] a list of Sep7Replacement objects. + * + * @returns {string} a string that identifies the fields to be replaced in the + * XDR using the 'Txrep (SEP-0011)' representation. + */ +export const sep7ReplacementsToString = ( + replacements?: Sep7Replacement[], +): string => { + if (!replacements || replacements.length === 0) { + return ""; + } + + const hintsMap: { [id: string]: string } = {}; + + const txrepString = replacements + .map(({ id, hint, path }) => { + hintsMap[id] = hint; + + return `${path}${ID_DELIMITER}${id}`; + }) + .join(LIST_DELIMITER); + + const hintsString = Object.entries(hintsMap) + .map(([id, hint]) => `${id}${ID_DELIMITER}${hint}`) + .join(LIST_DELIMITER); + + return `${txrepString}${HINT_DELIMITER}${hintsString}`; +}; diff --git a/@stellar/typescript-wallet-sdk/src/walletSdk/Utils/index.ts b/@stellar/typescript-wallet-sdk/src/walletSdk/Utils/index.ts index 6c5a467..40637f5 100644 --- a/@stellar/typescript-wallet-sdk/src/walletSdk/Utils/index.ts +++ b/@stellar/typescript-wallet-sdk/src/walletSdk/Utils/index.ts @@ -1,4 +1,5 @@ export * from "./camelToSnakeCase"; +export * from "./extractAxiosErrorData"; +export * from "./getResultCode"; export * from "./toml"; export * from "./url"; -export * from "./extractAxiosErrorData"; diff --git a/@stellar/typescript-wallet-sdk/src/walletSdk/Watcher/index.ts b/@stellar/typescript-wallet-sdk/src/walletSdk/Watcher/index.ts index fd88ee0..a9acec5 100644 --- a/@stellar/typescript-wallet-sdk/src/walletSdk/Watcher/index.ts +++ b/@stellar/typescript-wallet-sdk/src/walletSdk/Watcher/index.ts @@ -1,4 +1,6 @@ import { Anchor } from "../Anchor"; +import { Sep6 } from "../Anchor/Sep6"; +import { Sep24 } from "../Anchor/Sep24"; import { AnchorTransaction, TransactionStatus, @@ -329,7 +331,7 @@ export class Watcher { }; } - let sepObj; + let sepObj: Sep6 | Sep24; switch (this.sepType) { case WatcherSepType.SEP6: sepObj = this.anchor.sep6(); diff --git a/@stellar/typescript-wallet-sdk/test/sep7.test.ts b/@stellar/typescript-wallet-sdk/test/sep7.test.ts new file mode 100644 index 0000000..7f9dcf2 --- /dev/null +++ b/@stellar/typescript-wallet-sdk/test/sep7.test.ts @@ -0,0 +1,829 @@ +import { Keypair, Networks, StellarToml } from "@stellar/stellar-sdk"; +import { + NativeAssetId, + Sep7Pay, + Sep7Tx, + SigningKeypair, + Stellar, + Wallet, + isValidSep7Uri, + parseSep7Uri, + sep7ReplacementsFromString, + sep7ReplacementsToString, +} from "../src"; +import { Sep7OperationType } from "../src/walletSdk/Types"; +import { + Sep7InvalidUriError, + Sep7LongMsgError, +} from "../src/walletSdk/Exceptions"; + +const testKp1 = SigningKeypair.fromSecret( + "SBKQDF56C5VY2YQTNQFGY7HM6R3V6QKDUEDXZQUCPQOP2EBZWG2QJ2JL", +); + +const testKp2 = SigningKeypair.fromSecret( + "SBIK5MF5QONDTKA5ZPXLI2XTBIAOWQEEOZ3TM76XVBPPJ2EEUUXTCIVZ", +); + +const xdrs = { + classic: encodeURIComponent( + "AAAAAgAAAACCMXQVfkjpO2gAJQzKsUsPfdBCyfrvy7sr8+35cOxOSwAAAGQABqQMAAAAAQAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAACCMXQVfkjpO2gAJQzKsUsPfdBCyfrvy7sr8+35cOxOSwAAAAAAmJaAAAAAAAAAAAFw7E5LAAAAQBu4V+/lttEONNM6KFwdSf5TEEogyEBy0jTOHJKuUzKScpLHyvDJGY+xH9Ri4cIuA7AaB8aL+VdlucCfsNYpKAY=", + ), + sorobanTransfer: encodeURIComponent( + "AAAAAgAAAACM6IR9GHiRoVVAO78JJNksy2fKDQNs2jBn8bacsRLcrDucaFsAAAWIAAAAMQAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAGAAAAAAAAAABHkEVdJ+UfDnWpBr/qF582IEoDQ0iW0WPzO9CEUdvvh8AAAAIdHJhbnNmZXIAAAADAAAAEgAAAAAAAAAAjOiEfRh4kaFVQDu/CSTZLMtnyg0DbNowZ/G2nLES3KwAAAASAAAAAAAAAADoFl2ACT9HZkbCeuaT9MAIdStpdf58wM3P24nl738AnQAAAAoAAAAAAAAAAAAAAAAAAAAFAAAAAQAAAAAAAAAAAAAAAR5BFXSflHw51qQa/6hefNiBKA0NIltFj8zvQhFHb74fAAAACHRyYW5zZmVyAAAAAwAAABIAAAAAAAAAAIzohH0YeJGhVUA7vwkk2SzLZ8oNA2zaMGfxtpyxEtysAAAAEgAAAAAAAAAA6BZdgAk/R2ZGwnrmk/TACHUraXX+fMDNz9uJ5e9/AJ0AAAAKAAAAAAAAAAAAAAAAAAAABQAAAAAAAAABAAAAAAAAAAIAAAAGAAAAAR5BFXSflHw51qQa/6hefNiBKA0NIltFj8zvQhFHb74fAAAAFAAAAAEAAAAHa35L+/RxV6EuJOVk78H5rCN+eubXBWtsKrRxeLnnpRAAAAACAAAABgAAAAEeQRV0n5R8OdakGv+oXnzYgSgNDSJbRY/M70IRR2++HwAAABAAAAABAAAAAgAAAA8AAAAHQmFsYW5jZQAAAAASAAAAAAAAAACM6IR9GHiRoVVAO78JJNksy2fKDQNs2jBn8bacsRLcrAAAAAEAAAAGAAAAAR5BFXSflHw51qQa/6hefNiBKA0NIltFj8zvQhFHb74fAAAAEAAAAAEAAAACAAAADwAAAAdCYWxhbmNlAAAAABIAAAAAAAAAAOgWXYAJP0dmRsJ65pP0wAh1K2l1/nzAzc/bieXvfwCdAAAAAQBkcwsAACBwAAABKAAAAAAAAB1kAAAAAA==", + ), + sorobanMint: encodeURIComponent( + "AAAAAgAAAACM6IR9GHiRoVVAO78JJNksy2fKDQNs2jBn8bacsRLcrDucQIQAAAWIAAAAMQAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAGAAAAAAAAAABHkEVdJ+UfDnWpBr/qF582IEoDQ0iW0WPzO9CEUdvvh8AAAAEbWludAAAAAIAAAASAAAAAAAAAADoFl2ACT9HZkbCeuaT9MAIdStpdf58wM3P24nl738AnQAAAAoAAAAAAAAAAAAAAAAAAAAFAAAAAQAAAAAAAAAAAAAAAR5BFXSflHw51qQa/6hefNiBKA0NIltFj8zvQhFHb74fAAAABG1pbnQAAAACAAAAEgAAAAAAAAAA6BZdgAk/R2ZGwnrmk/TACHUraXX+fMDNz9uJ5e9/AJ0AAAAKAAAAAAAAAAAAAAAAAAAABQAAAAAAAAABAAAAAAAAAAIAAAAGAAAAAR5BFXSflHw51qQa/6hefNiBKA0NIltFj8zvQhFHb74fAAAAFAAAAAEAAAAHa35L+/RxV6EuJOVk78H5rCN+eubXBWtsKrRxeLnnpRAAAAABAAAABgAAAAEeQRV0n5R8OdakGv+oXnzYgSgNDSJbRY/M70IRR2++HwAAABAAAAABAAAAAgAAAA8AAAAHQmFsYW5jZQAAAAASAAAAAAAAAADoFl2ACT9HZkbCeuaT9MAIdStpdf58wM3P24nl738AnQAAAAEAYpBIAAAfrAAAAJQAAAAAAAAdYwAAAAA=", + ), +}; + +let wal: Wallet; +let stellar: Stellar; + +describe("Sep7Base", () => { + it("constructor accepts a string uri", () => { + const uriStr = + "web+stellar:tx?xdr=test&callback=https%3A%2F%2Fexample.com%2Fcallback"; + const uri = new Sep7Tx(uriStr); + expect(uri.operationType).toBe(Sep7OperationType.tx); + expect(uri.xdr).toBe("test"); + expect(uri.callback).toBe("https://example.com/callback"); + expect(uri.toString()).toBe(uriStr); + }); + + it("constructor accepts URL uri", () => { + const uriStr = + "web+stellar:tx?xdr=test&callback=https%3A%2F%2Fexample.com%2Fcallback"; + const url = new URL(uriStr); + const uri = new Sep7Tx(url); + expect(uri.operationType).toBe(Sep7OperationType.tx); + expect(uri.xdr).toBe("test"); + expect(uri.callback).toBe("https://example.com/callback"); + expect(uri.toString()).toBe(uriStr); + + // should not hold a reference to the original URL + url.searchParams.delete("callback"); + expect(uri.callback).toBe("https://example.com/callback"); + }); + + it("should default to public network if not set", () => { + const uri = new Sep7Tx("web+stellar:tx"); + expect(uri.networkPassphrase).toBe(Networks.PUBLIC); + + uri.networkPassphrase = Networks.TESTNET; + expect(uri.networkPassphrase).toBe(Networks.TESTNET); + }); + + it("allows setting callback with or without 'url:' prefix", () => { + const uri = new Sep7Tx("web+stellar:tx"); + expect(uri.operationType).toBe(Sep7OperationType.tx); + expect(uri.callback).toBe(undefined); + + // should remove "url:" prefix when getting + uri.callback = "url:https://example.com/callback"; + expect(uri.callback).toBe("https://example.com/callback"); + + uri.callback = "https://example.com/callback"; + expect(uri.callback).toBe("https://example.com/callback"); + + expect(uri.toString()).toBe( + "web+stellar:tx?callback=url%3Ahttps%3A%2F%2Fexample.com%2Fcallback", + ); + }); + + it("get/set msg", () => { + const uri = new Sep7Tx("web+stellar:tx?msg=test%20message"); + + expect(uri.msg).toBe("test message"); + + uri.msg = "another message"; + expect(uri.msg).toBe("another message"); + }); + + it("msg throws error if longer than 300 chars", () => { + // Should throw when creating from uri string + try { + new Sep7Tx( + "web+stellar:tx?msg=test%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20message%20test%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20message", + ); + } catch (error) { + expect(error).toBeInstanceOf(Sep7LongMsgError); + } + + const uri = new Sep7Tx("web+stellar:tx?msg=test%20message"); + + // Should throw when setting 'msg' with existing uri + try { + uri.msg = + "another long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long message"; + } catch (error) { + expect(error).toBeInstanceOf(Sep7LongMsgError); + } + }); + + it("get/set network_passphrase", () => { + const uri = new Sep7Tx("web+stellar:tx?msg=test%20message"); + + // if not present on the uri it should default to Public network + expect(uri.networkPassphrase).toBe(Networks.PUBLIC); + + uri.networkPassphrase = Networks.TESTNET; + expect(uri.networkPassphrase).toBe(Networks.TESTNET); + }); + + it("get/set origin_domain", () => { + const uri = new Sep7Tx( + "web+stellar:tx?msg=test%20message&origin_domain=someDomain.com", + ); + + expect(uri.originDomain).toBe("someDomain.com"); + + uri.originDomain = "anotherDomain.com"; + expect(uri.originDomain).toBe("anotherDomain.com"); + }); + + it("addSignature() signs the uri and adds a signature to the end", () => { + const uriStr = + "web+stellar:pay?destination=GCALNQQBXAPZ2WIRSDDBMSTAKCUH5SG6U76YBFLQLIXJTF7FE5AX7AOO&amount=120.1234567&memo=skdjfasf&msg=pay%20me%20with%20lumens&origin_domain=someDomain.com"; + const uri = new Sep7Pay(uriStr); + + uri.addSignature(Keypair.fromSecret(testKp1.secretKey)); + + const expectedSignature = + "juY2Pi1/IubcbIDds2CbnL+Imr7dbpJYMW1nLAesOmyh5v/uTVvJwI06RgCGBtHh5+5DWOhJUlEfOSGXPtqgAA=="; + + expect(uri.signature).toBe(expectedSignature); + expect( + uri + .toString() + .endsWith(`&signature=${encodeURIComponent(expectedSignature)}`), + ).toBe(true); + }); + + it("verifySignature() returns false when there is no origin_domain and signature", async () => { + const uriStr = "web+stellar:tx?xdr=test"; + const uri = new Sep7Tx(uriStr); + expect(await uri.verifySignature()).toBe(false); + }); + + it("verifySignature() returns false when there is origin domain but no signature", async () => { + const uriStr = "web+stellar:tx?xdr=test&origin_domain=someDomain.com"; + const uri = new Sep7Tx(uriStr); + expect(await uri.verifySignature()).toBe(false); + }); + + it("verifySignature() returns false when there is signature but no origin domain", async () => { + const uriStr = "web+stellar:tx?xdr=test&signature=sig"; + const uri = new Sep7Tx(uriStr); + expect(await uri.verifySignature()).toBe(false); + }); + + it("verifySignature() returns false when the stellar.toml fails to resolve", async () => { + const uriStr = + "web+stellar:tx?xdr=test&origin_domain=someDomain.com&signature=sig"; + const uri = new Sep7Tx(uriStr); + + jest + .spyOn(StellarToml.Resolver, "resolve") + .mockRejectedValue(new Error("Not Found")); + + expect(await uri.verifySignature()).toBe(false); + }); + + it("verifySignature() returns false when the stellar.toml has no URI_REQUEST_SIGNING_KEY field", async () => { + const uriStr = + "web+stellar:tx?xdr=test&origin_domain=someDomain.com&signature=sig"; + const uri = new Sep7Tx(uriStr); + + jest.spyOn(StellarToml.Resolver, "resolve").mockResolvedValue({}); + + expect(await uri.verifySignature()).toBe(false); + }); + + it("verifySignature() returns false when the signature is not valid", async () => { + const uriStr = + "web+stellar:pay?destination=GCALNQQBXAPZ2WIRSDDBMSTAKCUH5SG6U76YBFLQLIXJTF7FE5AX7AOO&amount=120.1234567&memo=skdjfasf&msg=pay%20me%20with%20lumens&origin_domain=someDomain.com?signature=invalid"; + const uri = new Sep7Pay(uriStr); + + jest.spyOn(StellarToml.Resolver, "resolve").mockResolvedValue({ + URI_REQUEST_SIGNING_KEY: testKp1.publicKey, + }); + + expect(await uri.verifySignature()).toBe(false); + }); + + it("verifySignature() returns true when the signature is valid", async () => { + const uriStr = + "web+stellar:pay?destination=GCALNQQBXAPZ2WIRSDDBMSTAKCUH5SG6U76YBFLQLIXJTF7FE5AX7AOO&amount=120.1234567&memo=skdjfasf&msg=pay%20me%20with%20lumens&origin_domain=someDomain.com&signature=juY2Pi1%2FIubcbIDds2CbnL%2BImr7dbpJYMW1nLAesOmyh5v%2FuTVvJwI06RgCGBtHh5%2B5DWOhJUlEfOSGXPtqgAA%3D%3D"; + const uri = new Sep7Pay(uriStr); + + jest.spyOn(StellarToml.Resolver, "resolve").mockResolvedValue({ + URI_REQUEST_SIGNING_KEY: testKp1.publicKey, + }); + + expect(await uri.verifySignature()).toBe(true); + }); +}); + +describe("Sep7Tx", () => { + beforeAll(async () => { + wal = Wallet.TestNet(); + stellar = wal.stellar(); + + try { + await stellar.server.loadAccount(testKp1.publicKey); + await stellar.server.loadAccount(testKp2.publicKey); + } catch (e) { + await stellar.fundTestnetAccount(testKp1.publicKey); + await stellar.fundTestnetAccount(testKp2.publicKey); + } + }, 30000); + + it("forTransaction sets the tx parameter", async () => { + const txBuilder = await stellar.transaction({ + sourceAddress: testKp1, + baseFee: 100, + timebounds: 0, + }); + txBuilder.transfer(testKp2.publicKey, new NativeAssetId(), "1"); + const tx = txBuilder.build(); + + const xdr = tx.toEnvelope().toXDR().toString("base64"); + + const uri = Sep7Tx.forTransaction(tx); + expect(uri.operationType).toBe("tx"); + expect(uri.xdr).toBe(xdr); + expect(uri.toString()).toBe( + `web+stellar:tx?xdr=${encodeURIComponent( + xdr, + )}&network_passphrase=Test+SDF+Network+%3B+September+2015`, + ); + }); + + it("constructor accepts a string uri", () => { + const uriStr = + "web+stellar:tx?xdr=test&callback=https%3A%2F%2Fexample.com%2Fcallback"; + const uri = new Sep7Tx(uriStr); + expect(uri.operationType).toBe("tx"); + expect(uri.xdr).toBe("test"); + expect(uri.callback).toBe("https://example.com/callback"); + expect(uri.toString()).toBe(uriStr); + }); + + it("allows adding xdr after construction", () => { + const uri = new Sep7Tx(); + uri.xdr = "test"; + expect(uri.xdr).toBe("test"); + expect(uri.toString()).toBe("web+stellar:tx?xdr=test"); + }); + + it("get/set xdr", () => { + const uri = new Sep7Tx( + "web+stellar:tx?xdr=testA&pubkey=testPubkey&callback=https%3A%2F%2Fexample.com%2Fcallback", + ); + + expect(uri.xdr).toBe("testA"); + + uri.xdr = "testB"; + expect(uri.xdr).toBe("testB"); + }); + + it("get/set pubkey", () => { + const uri = new Sep7Tx( + "web+stellar:tx?xdr=test&pubkey=testPubkey&callback=https%3A%2F%2Fexample.com%2Fcallback", + ); + + expect(uri.pubkey).toBe("testPubkey"); + + uri.pubkey = testKp1.publicKey; + expect(uri.pubkey).toBe(testKp1.publicKey); + }); + + it("get/set chain", () => { + const uri = new Sep7Tx( + "web+stellar:tx?xdr=test&pubkey=testPubkey&callback=https%3A%2F%2Fexample.com%2Fcallback&chain=testChain", + ); + + expect(uri.chain).toBe("testChain"); + + uri.chain = + "web+stellar:pay?destination=GCALNQQBXAPZ2WIRSDDBMSTAKCUH5SG6U76YBFLQLIXJTF7FE5AX7AOO&amount=120.1234567&memo=skdjfasf&msg=pay%20me%20with%20lumens&origin_domain=someDomain.com&signature=juY2Pi1%2FIubcbIDds2CbnL%2BImr7dbpJYMW1nLAesOmyh5v%2FuTVvJwI06RgCGBtHh5%2B5DWOhJUlEfOSGXPtqgAA%3D%3D"; + expect(uri.chain).toBe( + "web+stellar:pay?destination=GCALNQQBXAPZ2WIRSDDBMSTAKCUH5SG6U76YBFLQLIXJTF7FE5AX7AOO&amount=120.1234567&memo=skdjfasf&msg=pay%20me%20with%20lumens&origin_domain=someDomain.com&signature=juY2Pi1%2FIubcbIDds2CbnL%2BImr7dbpJYMW1nLAesOmyh5v%2FuTVvJwI06RgCGBtHh5%2B5DWOhJUlEfOSGXPtqgAA%3D%3D", + ); + }); + + it("parses replacements", () => { + // from doc sample: https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0007.md#operation-tx + const uri = new Sep7Tx( + "web+stellar:tx?replace=sourceAccount%3AX%2Coperations%5B0%5D.sourceAccount%3AY%2Coperations%5B1%5D.destination%3AY%3BX%3Aaccount%20from%20where%20you%20want%20to%20pay%20fees%2CY%3Aaccount%20that%20needs%20the%20trustline%20and%20which%20will%20receive%20the%20new%20tokens", + ); + + const replacements = uri.getReplacements(); + + expect(replacements.length).toBe(3); + + expect(replacements[0].id).toBe("X"); + expect(replacements[0].path).toBe("sourceAccount"); + expect(replacements[0].hint).toBe( + "account from where you want to pay fees", + ); + + expect(replacements[1].id).toBe("Y"); + expect(replacements[1].path).toBe("operations[0].sourceAccount"); + expect(replacements[1].hint).toBe( + "account that needs the trustline and which will receive the new tokens", + ); + + expect(replacements[2].id).toBe("Y"); + expect(replacements[2].path).toBe("operations[1].destination"); + expect(replacements[2].hint).toBe( + "account that needs the trustline and which will receive the new tokens", + ); + }); + + it("addReplacement", () => { + const uri = new Sep7Tx("web+stellar:tx"); + uri.addReplacement({ + id: "X", + path: "sourceAccount", + hint: "account from where you want to pay fees", + }); + + uri.addReplacement({ + id: "Y", + path: "operations[0].sourceAccount", + hint: "account that needs the trustline and which will receive the new tokens", + }); + + uri.addReplacement({ + id: "Y", + path: "operations[1].destination", + hint: "account that needs the trustline and which will receive the new tokens", + }); + + expect(uri.toString()).toBe( + "web+stellar:tx?replace=sourceAccount%3AX%2Coperations%5B0%5D.sourceAccount%3AY%2Coperations%5B1%5D.destination%3AY%3BX%3Aaccount+from+where+you+want+to+pay+fees%2CY%3Aaccount+that+needs+the+trustline+and+which+will+receive+the+new+tokens", + ); + }); + + it("addReplacement with forTransaction", async () => { + const txBuilder = await stellar.transaction({ + sourceAddress: testKp1, + baseFee: 100, + timebounds: 0, + }); + txBuilder.transfer(testKp2.publicKey, new NativeAssetId(), "1"); + const tx = txBuilder.build(); + + const xdr = tx.toEnvelope().toXDR().toString("base64"); + + const uri = Sep7Tx.forTransaction(tx); + + uri.addReplacement({ + id: "SRC", + path: "sourceAccount", + hint: "source account", + }); + uri.addReplacement({ id: "SEQ", path: "seqNum", hint: "sequence number" }); + uri.addReplacement({ id: "FEE", path: "fee", hint: "fee" }); + + expect(uri.toString()).toBe( + `web+stellar:tx?xdr=${encodeURIComponent( + xdr, + )}&network_passphrase=Test+SDF+Network+%3B+September+2015&replace=sourceAccount%3ASRC%2CseqNum%3ASEQ%2Cfee%3AFEE%3BSRC%3Asource+account%2CSEQ%3Asequence+number%2CFEE%3Afee`, + ); + }); + + it("setReplacements", () => { + const uri = new Sep7Tx("web+stellar:tx"); + uri.setReplacements([ + { + id: "X", + path: "sourceAccount", + hint: "account from where you want to pay fees", + }, + { + id: "Y", + path: "operations[0].sourceAccount", + hint: "account that needs the trustline and which will receive the new tokens", + }, + { + id: "Y", + path: "operations[1].destination", + hint: "account that needs the trustline and which will receive the new tokens", + }, + ]); + + expect(uri.toString()).toBe( + "web+stellar:tx?replace=sourceAccount%3AX%2Coperations%5B0%5D.sourceAccount%3AY%2Coperations%5B1%5D.destination%3AY%3BX%3Aaccount+from+where+you+want+to+pay+fees%2CY%3Aaccount+that+needs+the+trustline+and+which+will+receive+the+new+tokens", + ); + }); + + it("removeReplacement", () => { + const uri = new Sep7Tx( + "web+stellar:tx?replace=sourceAccount%3AX%2Coperations%5B0%5D.sourceAccount%3AY%2Coperations%5B1%5D.destination%3AY%3BX%3Aaccount%20from%20where%20you%20want%20to%20pay%20fees%2CY%3Aaccount%20that%20needs%20the%20trustline%20and%20which%20will%20receive%20the%20new%20tokens", + ); + uri.removeReplacement("Y"); + + expect(uri.getReplacements().length).toBe(1); + expect(uri.toString()).toBe( + "web+stellar:tx?replace=sourceAccount%3AX%3BX%3Aaccount+from+where+you+want+to+pay+fees", + ); + }); + + it("geTransaction", async () => { + const txBuilder = await stellar.transaction({ + sourceAddress: testKp1, + baseFee: 100, + timebounds: 0, + }); + txBuilder.transfer(testKp2.publicKey, new NativeAssetId(), "1"); + const tx = txBuilder.build(); + + const xdr = tx.toEnvelope().toXDR().toString("base64"); + + const uri = new Sep7Tx(`web+stellar:tx?xdr=${encodeURIComponent(xdr)}`); + + expect(uri.getTransaction().toEnvelope().toXDR().toString("base64")).toBe( + xdr, + ); + }); +}); + +describe("Sep7Pay", () => { + it("forDestination sets the destination parameter", () => { + const uri = Sep7Pay.forDestination(testKp2.publicKey); + + expect(uri.operationType).toBe("pay"); + expect(uri.destination).toBe(testKp2.publicKey); + expect(uri.toString()).toBe( + `web+stellar:pay?destination=${testKp2.publicKey}`, + ); + }); + + it("get/set destination", () => { + const uri = new Sep7Pay(`web+stellar:pay?destination=${testKp2.publicKey}`); + + expect(uri.destination).toBe(testKp2.publicKey); + + uri.destination = "other"; + expect(uri.destination).toBe("other"); + }); + + it("get/set amount", () => { + const uri = new Sep7Pay( + `web+stellar:pay?destination=${testKp2.publicKey}&amount=12.3`, + ); + + expect(uri.amount).toBe("12.3"); + + uri.amount = "4"; + expect(uri.amount).toBe("4"); + }); + + it("get/set assetCode", () => { + const uri = new Sep7Pay( + `web+stellar:pay?destination=${testKp2.publicKey}&asset_code=USDC`, + ); + + expect(uri.assetCode).toBe("USDC"); + + uri.assetCode = "BRLT"; + expect(uri.assetCode).toBe("BRLT"); + }); + + it("get/set assetIssuer", () => { + const uri = new Sep7Pay( + `web+stellar:pay?destination=${testKp2.publicKey}&asset_issuer=issuerA`, + ); + + expect(uri.assetIssuer).toBe("issuerA"); + uri.assetIssuer = "issuerB"; + expect(uri.assetIssuer).toBe("issuerB"); + }); + + it("get/set memo", () => { + const uri = new Sep7Pay( + `web+stellar:pay?destination=${testKp2.publicKey}&memo=hello+world`, + ); + + expect(uri.memo).toBe("hello world"); + + uri.memo = "bye bye world"; + expect(uri.memo).toBe("bye bye world"); + }); + + it("get/set memoType", () => { + const uri = new Sep7Pay( + `web+stellar:pay?destination=${testKp2.publicKey}&memo_type=text`, + ); + + expect(uri.memoType).toBe("text"); + + uri.memoType = "id"; + expect(uri.memoType).toBe("id"); + }); +}); + +describe("sep7Parser", () => { + it("isValidSep7Uri(uri) returns true when it starts with 'web+stellar:tx?xdr='", () => { + expect( + isValidSep7Uri( + "web+stellar:tx?xdr=AAAAAP%2Byw%2BZEuNg533pUmwlYxfrq6%2FBoMJqiJ8vuQhf6rHWmAAAAZAB8NHAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAYAAAABSFVHAAAAAABAH0wIyY3BJBS2qHdRPAV80M8hF7NBpxRjXyjuT9kEbH%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FAAAAAAAAAAA%3D&callback=url%3Ahttps%3A%2F%2FsomeSigningService.com%2Fa8f7asdfkjha&pubkey=GAU2ZSYYEYO5S5ZQSMMUENJ2TANY4FPXYGGIMU6GMGKTNVDG5QYFW6JS&msg=order%20number%2024", + ).result, + ).toBe(true); + + expect( + isValidSep7Uri( + "web+stellar:tx?xdr=AAAAAP%2Byw%2BZEuNg533pUmwlYxfrq6%2FBoMJqiJ8vuQhf6rHWmAAAAZAB8NHAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAYAAAABSFVHAAAAAABAH0wIyY3BJBS2qHdRPAV80M8hF7NBpxRjXyjuT9kEbH%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FAAAAAAAAAAA%3D&replace=sourceAccount%3AX%3BX%3Aaccount%20on%20which%20to%20create%20the%20trustline", + ).result, + ).toBe(true); + + expect( + isValidSep7Uri( + `web+stellar:tx?xdr=${xdrs.classic}&replace=sourceAccount%3AX%3BX%3Aaccount%20on%20which%20to%20create%20the%20trustline`, + ).result, + ).toBe(true); + + expect( + isValidSep7Uri( + `web+stellar:tx?xdr=${xdrs.sorobanTransfer}&replace=sourceAccount%3AX%3BX%3Aaccount%20on%20which%20to%20create%20the%20trustline`, + ).result, + ).toBe(true); + + expect( + isValidSep7Uri( + `web+stellar:tx?xdr=${xdrs.sorobanMint}&replace=sourceAccount%3AX%3BX%3Aaccount%20on%20which%20to%20create%20the%20trustline`, + ).result, + ).toBe(true); + }); + + it("isValidSep7Uri(uri) returns true when it starts with 'web+stellar:pay?destination='", () => { + expect( + isValidSep7Uri( + "web+stellar:pay?destination=GCALNQQBXAPZ2WIRSDDBMSTAKCUH5SG6U76YBFLQLIXJTF7FE5AX7AOO&amount=120.1234567&memo=skdjfasf&memo_type=MEMO_TEXT&msg=pay%20me%20with%20lumens", + ).result, + ).toBe(true); + expect( + isValidSep7Uri( + "web+stellar:pay?destination=GCALNQQBXAPZ2WIRSDDBMSTAKCUH5SG6U76YBFLQLIXJTF7FE5AX7AOO&amount=120.123&asset_code=USD&asset_issuer=GCRCUE2C5TBNIPYHMEP7NK5RWTT2WBSZ75CMARH7GDOHDDCQH3XANFOB&memo=hasysda987fs&memo_type=MEMO_TEXT&callback=url%3Ahttps%3A%2F%2FsomeSigningService.com%2Fhasysda987fs%3Fasset%3DUSD", + ).result, + ).toBe(true); + + // With Muxed destination + expect( + isValidSep7Uri( + "web+stellar:pay?destination=MA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAAAAAABUTGI4&amount=120.1234567&memo=skdjfasf&memo_type=MEMO_TEXT&msg=pay%20me%20with%20lumens", + ).result, + ).toBe(true); + + // With Soroban Contract destination + // TODO: add support for this once "@stellar/stellar-sdk" + // package is updated to version >= "v12.0.1". + // expect( + // isValidSep7Uri( + // "web+stellar:pay?destination=CAPECFLUT6KHYOOWUQNP7KC6PTMICKANBURFWRMPZTXUEEKHN67B7UI2&amount=120.1234567&memo=skdjfasf&memo_type=MEMO_TEXT&msg=pay%20me%20with%20lumens", + // ).result, + // ).toBe(true); + }); + + it("isValidSep7Uri(uri) returns 'false' with 'reason' when it is not valid in some way", () => { + const validation1 = isValidSep7Uri( + "not-a-stellar-uri:tx?xdr=AAAAAP%2Byw%2BZEuNg533pUmwlYxfrq6%2FBoMJqiJ8vuQhf6rHWmAAAAZAB8NHAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAYAAAABSFVHAAAAAABAH0wIyY3BJBS2qHdRPAV80M8hF7NBpxRjXyjuT9kEbH%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FAAAAAAAAAAA%3D&callback=url%3Ahttps%3A%2F%2FsomeSigningService.com%2Fa8f7asdfkjha&pubkey=GAU2ZSYYEYO5S5ZQSMMUENJ2TANY4FPXYGGIMU6GMGKTNVDG5QYFW6JS&msg=order%20number%2024", + ); + expect(validation1.result).toBe(false); + expect(validation1.reason).toBe("it must start with 'web+stellar:'"); + + const validation2 = isValidSep7Uri( + "web+steIIar:pay?destination=GCALNQQBXAPZ2WIRSDDBMSTAKCUH5SG6U76YBFLQLIXJTF7FE5AX7AOO&amount=120.1234567&memo=skdjfasf&memo_type=MEMO_TEXT&msg=pay%20me%20with%20lumens", + ); + expect(validation2.result).toBe(false); + expect(validation2.reason).toBe("it must start with 'web+stellar:'"); + + const validation3 = isValidSep7Uri( + "web-stellar:pay?destination=GCALNQQBXAPZ2WIRSDDBMSTAKCUH5SG6U76YBFLQLIXJTF7FE5AX7AOO&amount=120.1234567&memo=skdjfasf&memo_type=MEMO_TEXT&msg=pay%20me%20with%20lumens", + ); + expect(validation3.result).toBe(false); + expect(validation3.reason).toBe("it must start with 'web+stellar:'"); + + const validation4 = isValidSep7Uri( + "web+stellar:send?xdr=AAAAAP%2Byw%2BZEuNg533pUmwlYxfrq6%2FBoMJqiJ8vuQhf6rHWmAAAAZAB8NHAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAYAAAABSFVHAAAAAABAH0wIyY3BJBS2qHdRPAV80M8hF7NBpxRjXyjuT9kEbH%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FAAAAAAAAAAA%3D&callback=url%3Ahttps%3A%2F%2FsomeSigningService.com%2Fa8f7asdfkjha&pubkey=GAU2ZSYYEYO5S5ZQSMMUENJ2TANY4FPXYGGIMU6GMGKTNVDG5QYFW6JS&msg=order%20number%2024", + ); + expect(validation4.result).toBe(false); + expect(validation4.reason).toBe( + "operation type 'send' is not currently supported", + ); + + const validation5 = isValidSep7Uri( + "web+stellar:pay?xdr=AAAAAP%2Byw%2BZEuNg533pUmwlYxfrq6%2FBoMJqiJ8vuQhf6rHWmAAAAZAB8NHAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAYAAAABSFVHAAAAAABAH0wIyY3BJBS2qHdRPAV80M8hF7NBpxRjXyjuT9kEbH%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FAAAAAAAAAAA%3D&callback=url%3Ahttps%3A%2F%2FsomeSigningService.com%2Fa8f7asdfkjha&pubkey=GAU2ZSYYEYO5S5ZQSMMUENJ2TANY4FPXYGGIMU6GMGKTNVDG5QYFW6JS&msg=order%20number%2024", + ); + expect(validation5.result).toBe(false); + expect(validation5.reason).toBe( + "operation type 'pay' must have a 'destination' parameter", + ); + + const validation6 = isValidSep7Uri( + "web+stellar:tx?destination=GCALNQQBXAPZ2WIRSDDBMSTAKCUH5SG6U76YBFLQLIXJTF7FE5AX7AOO&amount=120.1234567&memo=skdjfasf&memo_type=MEMO_TEXT&msg=pay%20me%20with%20lumens", + ); + expect(validation6.result).toBe(false); + expect(validation6.reason).toBe( + "operation type 'tx' must have a 'xdr' parameter", + ); + + const validation7 = isValidSep7Uri( + "web+stellar:tx?xdr=AAAAAP%2Byw%2BZEuNg533pUmwlYxfrq6%2FBoMJqiAAAAAAAAYAAAABSFVHAAAAAABAH0wIyY3BJBS2qHdRPAV80M8hF7NBpxRjXyjuT9kEbH%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FAAAAAAAAAAA%3D&replace=sourceAccount%3AX%3BX%3Aaccount%20on%20which%20to%20create%20the%20trustline", + ); + expect(validation7.result).toBe(false); + expect(validation7.reason).toBe( + "the provided 'xdr' parameter is not a valid transaction envelope on the 'Public Global Stellar Network ; September 2015' network", + ); + + const validation8 = isValidSep7Uri( + `web+stellar:tx?network_passphrase=${Networks.TESTNET}&xdr=AAAAAP%2Byw%2BZEuNg533pUmwlYxfrq6%2FBoMJqiAAAAAAAAYAAAABSFVHAAAAAABAH0wIyY3BJBS2qHdRPAV80M8hF7NBpxRjXyjuT9kEbH%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FAAAAAAAAAAA%3D&replace=sourceAccount%3AX%3BX%3Aaccount%20on%20which%20to%20create%20the%20trustline`, + ); + expect(validation8.result).toBe(false); + expect(validation8.reason).toBe( + "the provided 'xdr' parameter is not a valid transaction envelope on the 'Test SDF Network ; September 2015' network", + ); + + const validation9 = isValidSep7Uri( + "web+stellar:pay?destination=GCALNQQBXAPZ2WIRSDDBMST6U76YBFLQLIXJTF7FE5AX7AOO&amount=120.123&asset_code=USD&asset_issuer=GCRCUE2C5TBNIPYHMEP7NK5RWTT2WBSZ75CMARH7GDOHDDCQH3XANFOB&memo=hasysda987fs&memo_type=MEMO_TEXT&callback=url%3Ahttps%3A%2F%2FsomeSigningService.com%2Fhasysda987fs%3Fasset%3DUSD", + ); + expect(validation9.result).toBe(false); + expect(validation9.reason).toBe( + "the provided 'destination' parameter is not a valid Stellar address", + ); + + const validation10 = isValidSep7Uri( + `web+stellar:tx?xdr=${xdrs.classic}&msg=long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20message&replace=sourceAccount%3AX%3BX%3Aaccount%20on%20which%20to%20create%20the%20trustline`, + ); + expect(validation10.result).toBe(false); + expect(validation10.reason).toBe( + "the 'msg' parameter should be no longer than 300 characters", + ); + }); + + it("parseSep7Uri(uri) parses a valid 'tx' operation uri", () => { + expect( + parseSep7Uri( + "web+stellar:tx?xdr=AAAAAP%2Byw%2BZEuNg533pUmwlYxfrq6%2FBoMJqiJ8vuQhf6rHWmAAAAZAB8NHAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAYAAAABSFVHAAAAAABAH0wIyY3BJBS2qHdRPAV80M8hF7NBpxRjXyjuT9kEbH%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FAAAAAAAAAAA%3D&callback=url%3Ahttps%3A%2F%2FsomeSigningService.com%2Fa8f7asdfkjha&pubkey=GAU2ZSYYEYO5S5ZQSMMUENJ2TANY4FPXYGGIMU6GMGKTNVDG5QYFW6JS&msg=order%20number%2024", + ), + ).toBeInstanceOf(Sep7Tx); + expect( + parseSep7Uri( + "web+stellar:tx?xdr=AAAAAP%2Byw%2BZEuNg533pUmwlYxfrq6%2FBoMJqiJ8vuQhf6rHWmAAAAZAB8NHAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAYAAAABSFVHAAAAAABAH0wIyY3BJBS2qHdRPAV80M8hF7NBpxRjXyjuT9kEbH%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FAAAAAAAAAAA%3D&replace=sourceAccount%3AX%3BX%3Aaccount%20on%20which%20to%20create%20the%20trustline", + ), + ).toBeInstanceOf(Sep7Tx); + expect( + parseSep7Uri( + `web+stellar:tx?xdr=${xdrs.classic}&callback=url%3Ahttps%3A%2F%2FsomeSigningService.com%2Fa8f7asdfkjha&pubkey=GAU2ZSYYEYO5S5ZQSMMUENJ2TANY4FPXYGGIMU6GMGKTNVDG5QYFW6JS&msg=order%20number%2024`, + ), + ).toBeInstanceOf(Sep7Tx); + expect( + parseSep7Uri( + `web+stellar:tx?xdr=${xdrs.sorobanMint}&callback=url%3Ahttps%3A%2F%2FsomeSigningService.com%2Fa8f7asdfkjha&pubkey=GAU2ZSYYEYO5S5ZQSMMUENJ2TANY4FPXYGGIMU6GMGKTNVDG5QYFW6JS&msg=order%20number%2024`, + ), + ).toBeInstanceOf(Sep7Tx); + expect( + parseSep7Uri( + `web+stellar:tx?xdr=${xdrs.sorobanTransfer}&callback=url%3Ahttps%3A%2F%2FsomeSigningService.com%2Fa8f7asdfkjha&pubkey=GAU2ZSYYEYO5S5ZQSMMUENJ2TANY4FPXYGGIMU6GMGKTNVDG5QYFW6JS&msg=order%20number%2024`, + ), + ).toBeInstanceOf(Sep7Tx); + }); + + it("parseSep7Uri(uri) parses a valid 'pay' operation uri", () => { + expect( + parseSep7Uri( + "web+stellar:pay?destination=GCALNQQBXAPZ2WIRSDDBMSTAKCUH5SG6U76YBFLQLIXJTF7FE5AX7AOO&amount=120.1234567&memo=skdjfasf&memo_type=MEMO_TEXT&msg=pay%20me%20with%20lumens", + ), + ).toBeInstanceOf(Sep7Pay); + expect( + parseSep7Uri( + "web+stellar:pay?destination=GCALNQQBXAPZ2WIRSDDBMSTAKCUH5SG6U76YBFLQLIXJTF7FE5AX7AOO&amount=120.123&asset_code=USD&asset_issuer=GCRCUE2C5TBNIPYHMEP7NK5RWTT2WBSZ75CMARH7GDOHDDCQH3XANFOB&memo=hasysda987fs&memo_type=MEMO_TEXT&callback=url%3Ahttps%3A%2F%2FsomeSigningService.com%2Fhasysda987fs%3Fasset%3DUSD", + ), + ).toBeInstanceOf(Sep7Pay); + + // With Muxed destination + expect( + parseSep7Uri( + "web+stellar:pay?destination=MA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAAAAAABUTGI4&amount=120.123&asset_code=USD&asset_issuer=GCRCUE2C5TBNIPYHMEP7NK5RWTT2WBSZ75CMARH7GDOHDDCQH3XANFOB&memo=hasysda987fs&memo_type=MEMO_TEXT&callback=url%3Ahttps%3A%2F%2FsomeSigningService.com%2Fhasysda987fs%3Fasset%3DUSD", + ), + ).toBeInstanceOf(Sep7Pay); + + // With Soroban Contract destination + // TODO: add support for this once "@stellar/stellar-sdk" + // package is updated to version >= "v12.0.1". + // expect( + // parseSep7Uri( + // "web+stellar:pay?destination=CAPECFLUT6KHYOOWUQNP7KC6PTMICKANBURFWRMPZTXUEEKHN67B7UI2&amount=120.123&asset_code=USD&asset_issuer=GCRCUE2C5TBNIPYHMEP7NK5RWTT2WBSZ75CMARH7GDOHDDCQH3XANFOB&memo=hasysda987fs&memo_type=MEMO_TEXT&callback=url%3Ahttps%3A%2F%2FsomeSigningService.com%2Fhasysda987fs%3Fasset%3DUSD", + // ), + // ).toBeInstanceOf(Sep7Pay); + }); + + it("parseSep7Uri(uri) throws an error when it is not a valid Stellar uri in some way", () => { + try { + parseSep7Uri( + "not-a-stellar-uri:tx?xdr=AAAAAP%2Byw%2BZEuNg533pUmwlYxfrq6%2FBoMJqiJ8vuQhf6rHWmAAAAZAB8NHAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAYAAAABSFVHAAAAAABAH0wIyY3BJBS2qHdRPAV80M8hF7NBpxRjXyjuT9kEbH%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FAAAAAAAAAAA%3D&callback=url%3Ahttps%3A%2F%2FsomeSigningService.com%2Fa8f7asdfkjha&pubkey=GAU2ZSYYEYO5S5ZQSMMUENJ2TANY4FPXYGGIMU6GMGKTNVDG5QYFW6JS&msg=order%20number%2024", + ); + } catch (error) { + expect(error).toBeInstanceOf(Sep7InvalidUriError); + expect(error.toString()).toContain( + "Invalid Stellar Sep-7 URI, reason: it must start with 'web+stellar:'", + ); + } + + try { + parseSep7Uri( + "web+stellar:send?xdr=AAAAAP%2Byw%2BZEuNg533pUmwlYxfrq6%2FBoMJqiJ8vuQhf6rHWmAAAAZAB8NHAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAYAAAABSFVHAAAAAABAH0wIyY3BJBS2qHdRPAV80M8hF7NBpxRjXyjuT9kEbH%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FAAAAAAAAAAA%3D&callback=url%3Ahttps%3A%2F%2FsomeSigningService.com%2Fa8f7asdfkjha&pubkey=GAU2ZSYYEYO5S5ZQSMMUENJ2TANY4FPXYGGIMU6GMGKTNVDG5QYFW6JS&msg=order%20number%2024", + ); + } catch (error) { + expect(error).toBeInstanceOf(Sep7InvalidUriError); + expect(error.toString()).toContain( + "Invalid Stellar Sep-7 URI, reason: operation type 'send' is not currently supported", + ); + } + + try { + parseSep7Uri( + "web+stellar:tx?xdr=AAAAAP%2Byw%2BZEuNg533pUmwlYxfrq6%2FBoMJqiAAAAAAAAYAAAABSFVHAAAAAABAH0wIyY3BJBS2qHdRPAV80M8hF7NBpxRjXyjuT9kEbH%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FAAAAAAAAAAA%3D&replace=sourceAccount%3AX%3BX%3Aaccount%20on%20which%20to%20create%20the%20trustline", + ); + } catch (error) { + expect(error).toBeInstanceOf(Sep7InvalidUriError); + expect(error.toString()).toContain( + "Invalid Stellar Sep-7 URI, reason: the provided 'xdr' parameter is not a valid transaction envelope on the 'Public Global Stellar Network ; September 2015' network", + ); + } + + try { + parseSep7Uri( + "web+stellar:pay?destination=GCALNQQBXAPZ2WIRSDDBMST6U76YBFLQLIXJTF7FE5AX7AOO&amount=120.123&asset_code=USD&asset_issuer=GCRCUE2C5TBNIPYHMEP7NK5RWTT2WBSZ75CMARH7GDOHDDCQH3XANFOB&memo=hasysda987fs&memo_type=MEMO_TEXT&callback=url%3Ahttps%3A%2F%2FsomeSigningService.com%2Fhasysda987fs%3Fasset%3DUSD", + ); + } catch (error) { + expect(error).toBeInstanceOf(Sep7InvalidUriError); + expect(error.toString()).toContain( + "Invalid Stellar Sep-7 URI, reason: the provided 'destination' parameter is not a valid Stellar address", + ); + } + + try { + parseSep7Uri( + `web+stellar:tx?xdr=${xdrs.classic}&msg=long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20long%20message&replace=sourceAccount%3AX%3BX%3Aaccount%20on%20which%20to%20create%20the%20trustline`, + ); + } catch (error) { + expect(error).toBeInstanceOf(Sep7InvalidUriError); + expect(error.toString()).toContain( + "Invalid Stellar Sep-7 URI, reason: the 'msg' parameter should be no longer than 300 characters", + ); + } + }); + + it("sep7ReplacementsFromString() parses it successfully", () => { + const str = + "sourceAccount:X,operations[0].sourceAccount:Y,operations[1].destination:Y;X:account from where you want to pay fees,Y:account that needs the trustline and which will receive the new tokens"; + const replacements = sep7ReplacementsFromString(str); + + expect(replacements[0].id).toBe("X"); + expect(replacements[0].path).toBe("sourceAccount"); + expect(replacements[0].hint).toBe( + "account from where you want to pay fees", + ); + + expect(replacements[1].id).toBe("Y"); + expect(replacements[1].path).toBe("operations[0].sourceAccount"); + expect(replacements[1].hint).toBe( + "account that needs the trustline and which will receive the new tokens", + ); + + expect(replacements[2].id).toBe("Y"); + expect(replacements[2].path).toBe("operations[1].destination"); + expect(replacements[2].hint).toBe( + "account that needs the trustline and which will receive the new tokens", + ); + }); + + it("sep7ReplacementsToString outputs the right string", () => { + const expected = + "sourceAccount:X,operations[0].sourceAccount:Y,operations[1].destination:Y;X:account from where you want to pay fees,Y:account that needs the trustline and which will receive the new tokens"; + const replacements = [ + { + id: "X", + path: "sourceAccount", + hint: "account from where you want to pay fees", + }, + { + id: "Y", + path: "operations[0].sourceAccount", + hint: "account that needs the trustline and which will receive the new tokens", + }, + { + id: "Y", + path: "operations[1].destination", + hint: "account that needs the trustline and which will receive the new tokens", + }, + ]; + + const actual = sep7ReplacementsToString(replacements); + expect(actual).toBe(expected); + }); +});