diff --git a/apps/extension/src/Approvals/Approvals.tsx b/apps/extension/src/Approvals/Approvals.tsx index a6d71f702..47bedc60f 100644 --- a/apps/extension/src/Approvals/Approvals.tsx +++ b/apps/extension/src/Approvals/Approvals.tsx @@ -6,6 +6,7 @@ import { Container } from "@namada/components"; import { AppHeader } from "App/Common/AppHeader"; import { TopLevelRoute } from "Approvals/types"; +import { PendingTxDetails } from "background/approvals"; import { ApproveConnection } from "./ApproveConnection"; import { ApproveSignature } from "./ApproveSignature"; import { ApproveTx } from "./ApproveTx/ApproveTx"; @@ -20,12 +21,9 @@ export enum Status { } export type ApprovalDetails = { - source: string; msgId: string; txType: TxType; - publicKey?: string; - target?: string; - nativeToken?: string; + tx: PendingTxDetails[]; }; export type SignatureDetails = { @@ -50,8 +48,8 @@ export const Approvals: React.FC = () => { > } + path={`${TopLevelRoute.ApproveTx}/:msgId/:type/:accountType`} + element={} /> void; + details?: ApprovalDetails; }; -export const ApproveTx: React.FC = ({ setDetails }) => { +const fetchPendingTxDetails = async ( + requester: ExtensionRequester, + msgId: string +): Promise => { + return await requester.sendMessage( + Ports.Background, + new QueryPendingTxMsg(msgId) + ); +}; + +export const ApproveTx: React.FC = ({ details, setDetails }) => { const navigate = useNavigate(); const requester = useRequester(); - + // Parse URL params const params = useSanitizedParams(); const txType = parseInt(params?.type || "0"); - - const query = useQuery(); - const { - accountType, - msgId, - amount, - source, - target, - validator, - tokenAddress, - publicKey, - nativeToken, - } = query.getAll(); - - const tokenType = - Object.values(Tokens).find((token) => token.address === tokenAddress) - ?.symbol || "NAM"; + const accountType = + (params?.accountType as AccountType) || AccountType.PrivateKey; + const msgId = params?.msgId || "0"; useEffect(() => { - if (source && txType && msgId) { - setDetails({ - source, - txType, - msgId, - publicKey, - target, - nativeToken, + fetchPendingTxDetails(requester, msgId) + .then((details) => { + if (!details) { + throw new Error( + `Failed to fetch Tx details - no transactions exists for ${msgId}` + ); + } + // TODO: Handle array of approval details + setDetails({ + txType, + msgId, + tx: details, + }); + }) + .catch((e) => { + console.error(`Could not fetch pending Tx with msgId = ${msgId}: ${e}`); }); - } - }, [source, publicKey, txType, target, msgId]); + }, [txType, msgId]); const handleApproveClick = useCallback((): void => { if (accountType === AccountType.Ledger) { @@ -83,25 +91,45 @@ export const ApproveTx: React.FC = ({ setDetails }) => { {TxTypeLabel[txType as TxType]} transaction? - {source && ( -

- Source: {shortenAddress(source)} -

- )} - {target && ( -

- Target: - {shortenAddress(target)} -

- )} - {amount && ( -

- Amount: {amount} {tokenType} -

- )} - {validator && ( -

Validator: {shortenAddress(validator)}

- )} + {details?.tx.map((txDetails, i) => { + const { amount, source, target, publicKey, tokenAddress, validator } = + txDetails || {}; + const tokenType = + Object.values(Tokens).find( + (token) => token.address === tokenAddress + )?.symbol || "NAM"; + + return ( +
+ {source && ( +

+ Source: {shortenAddress(source)} +

+ )} + {target && ( +

+ Target: + {shortenAddress(target)} +

+ )} + {amount && ( +

+ Amount: {amount} {tokenType} +

+ )} + {publicKey && ( +

+ Public key: {shortenAddress(publicKey)} +

+ )} + {validator && ( +

+ Validator: {shortenAddress(validator)} +

+ )} +
+ ); + })}
Approve diff --git a/apps/extension/src/Approvals/ApproveTx/ConfirmLedgerTx.tsx b/apps/extension/src/Approvals/ApproveTx/ConfirmLedgerTx.tsx index 7236c0453..851bec8ef 100644 --- a/apps/extension/src/Approvals/ApproveTx/ConfirmLedgerTx.tsx +++ b/apps/extension/src/Approvals/ApproveTx/ConfirmLedgerTx.tsx @@ -31,7 +31,8 @@ export const ConfirmLedgerTx: React.FC = ({ details }) => { const [error, setError] = useState(); const [status, setStatus] = useState(); const [statusInfo, setStatusInfo] = useState(""); - const { source, msgId, publicKey, txType, nativeToken } = details || {}; + const { msgId, txType } = details || {}; + const { source, publicKey, nativeToken } = details?.tx[0] || {}; useEffect(() => { if (status === Status.Completed) { @@ -135,11 +136,13 @@ export const ConfirmLedgerTx: React.FC = ({ details }) => { throw new Error("msgId was not provided!"); } - const { bytes, path } = await requester - .sendMessage(Ports.Background, new GetTxBytesMsg(txType, msgId, source)) - .catch((e) => { - throw new Error(`Requester error: ${e}`); - }); + const { bytes, path } = ( + await requester + .sendMessage(Ports.Background, new GetTxBytesMsg(txType, msgId, source)) + .catch((e) => { + throw new Error(`Requester error: ${e}`); + }) + )[0]; setStatusInfo(`Review and approve ${txLabel} transaction on your Ledger`); diff --git a/apps/extension/src/Approvals/ApproveTx/ConfirmTx.tsx b/apps/extension/src/Approvals/ApproveTx/ConfirmTx.tsx index 3b301568e..826f27cff 100644 --- a/apps/extension/src/Approvals/ApproveTx/ConfirmTx.tsx +++ b/apps/extension/src/Approvals/ApproveTx/ConfirmTx.tsx @@ -1,9 +1,8 @@ import { useCallback, useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; -import { SupportedTx, TxType, TxTypeLabel } from "@heliax/namada-sdk/web"; +import { SupportedTx, TxTypeLabel } from "@heliax/namada-sdk/web"; import { ActionButton, Alert, Input, Stack } from "@namada/components"; -import { shortenAddress } from "@namada/utils"; import { ApprovalDetails, Status } from "Approvals/Approvals"; import { SubmitApprovedTxMsg } from "background/approvals"; import { UnlockVaultMsg } from "background/vault"; @@ -17,7 +16,7 @@ type Props = { }; export const ConfirmTx: React.FC = ({ details }) => { - const { source, msgId, txType } = details || {}; + const { msgId, txType } = details || {}; const navigate = useNavigate(); const requester = useRequester(); @@ -27,10 +26,12 @@ export const ConfirmTx: React.FC = ({ details }) => { const [statusInfo, setStatusInfo] = useState(""); const handleApproveTx = useCallback(async (): Promise => { + if (!txType) { + // TODO: What would be a better handling of this? txType should be defined + throw new Error("txType should be defined"); + } setStatus(Status.Pending); - setStatusInfo( - `Decrypting keys and submitting ${TxTypeLabel[txType as TxType]}...` - ); + setStatusInfo(`Decrypting keys and submitting ${TxTypeLabel[txType]}...`); try { if (!msgId) { @@ -96,12 +97,9 @@ export const ConfirmTx: React.FC = ({ details }) => { Try again )} - {status !== (Status.Pending || Status.Completed) && source && ( + {status !== (Status.Pending || Status.Completed) && ( <> - - Decrypt keys for{" "} - {shortenAddress(source)} - + Verify your password to continue { const approveTxMsg = new ApproveTxMsg( TxType.Bond, - "txMsg", - "specificMsg", + [ + { + txMsg: "txMsg", + specificMsg: "specificMsg", + }, + ], AccountType.Mnemonic ); handler(env, approveTxMsg); diff --git a/apps/extension/src/background/approvals/handler.ts b/apps/extension/src/background/approvals/handler.ts index 4c5604ba5..558eb0253 100644 --- a/apps/extension/src/background/approvals/handler.ts +++ b/apps/extension/src/background/approvals/handler.ts @@ -7,6 +7,7 @@ import { import { Env, Handler, InternalHandler, Message } from "router"; import { ConnectInterfaceResponseMsg, + QueryPendingTxMsg, RejectSignatureMsg, RejectTxMsg, RevokeConnectionMsg, @@ -27,6 +28,8 @@ export const getHandler: (service: ApprovalsService) => Handler = (service) => { env, msg as SubmitApprovedTxMsg ); + case QueryPendingTxMsg: + return handleQueryPendingTxMsg(service)(env, msg as QueryPendingTxMsg); case IsConnectionApprovedMsg: return handleIsConnectionApprovedMsg(service)( env, @@ -72,8 +75,8 @@ export const getHandler: (service: ApprovalsService) => Handler = (service) => { const handleApproveTxMsg: ( service: ApprovalsService ) => InternalHandler = (service) => { - return async (_, { txType, specificMsg, txMsg, accountType }) => { - return await service.approveTx(txType, specificMsg, txMsg, accountType); + return async (_, { txType, tx, accountType }) => { + return await service.approveTx(txType, tx, accountType); }; }; @@ -93,6 +96,14 @@ const handleSubmitApprovedTxMsg: ( }; }; +const handleQueryPendingTxMsg: ( + service: ApprovalsService +) => InternalHandler = (service) => { + return async (_, { msgId }) => { + return await service.queryPendingTx(msgId); + }; +}; + const handleIsConnectionApprovedMsg: ( service: ApprovalsService ) => InternalHandler = (service) => { diff --git a/apps/extension/src/background/approvals/init.ts b/apps/extension/src/background/approvals/init.ts index 9746575b6..82addab53 100644 --- a/apps/extension/src/background/approvals/init.ts +++ b/apps/extension/src/background/approvals/init.ts @@ -7,6 +7,7 @@ import { import { Router } from "router"; import { ConnectInterfaceResponseMsg, + QueryPendingTxMsg, RejectSignatureMsg, RejectTxMsg, RevokeConnectionMsg, @@ -21,6 +22,7 @@ import { ApprovalsService } from "./service"; export function init(router: Router, service: ApprovalsService): void { router.registerMessage(ApproveTxMsg); router.registerMessage(RejectTxMsg); + router.registerMessage(QueryPendingTxMsg); router.registerMessage(SubmitApprovedTxMsg); router.registerMessage(ApproveSignArbitraryMsg); router.registerMessage(RejectSignatureMsg); diff --git a/apps/extension/src/background/approvals/messages.ts b/apps/extension/src/background/approvals/messages.ts index 4a6b1f6a8..acd0069fd 100644 --- a/apps/extension/src/background/approvals/messages.ts +++ b/apps/extension/src/background/approvals/messages.ts @@ -3,10 +3,12 @@ import { Message } from "router"; import { ROUTE } from "./constants"; import { validateProps } from "utils"; +import { PendingTxDetails } from "./types"; export enum MessageType { - SubmitApprovedTx = "submit-approved-tx", RejectTx = "reject-tx", + SubmitApprovedTx = "submit-approved-tx", + QueryPendingTx = "query-pending-tx", SubmitApprovedSignature = "submit-approved-signature", RejectSignature = "reject-signature", ConnectInterfaceResponse = "connect-interface-response", @@ -23,10 +25,7 @@ export class RejectTxMsg extends Message { } validate(): void { - if (!this.msgId) { - throw new Error("msgId must not be empty!"); - } - return; + validateProps(this, ["msgId"]); } route(): string { @@ -63,9 +62,9 @@ export class SubmitApprovedTxMsg extends Message { } } -export class RejectSignatureMsg extends Message { +export class QueryPendingTxMsg extends Message { public static type(): MessageType { - return MessageType.RejectSignature; + return MessageType.QueryPendingTx; } constructor(public readonly msgId: string) { @@ -73,10 +72,7 @@ export class RejectSignatureMsg extends Message { } validate(): void { - if (!this.msgId) { - throw new Error("msgId must not be empty!"); - } - return; + validateProps(this, ["msgId"]); } route(): string { @@ -84,7 +80,7 @@ export class RejectSignatureMsg extends Message { } type(): string { - return RejectSignatureMsg.type(); + return QueryPendingTxMsg.type(); } } @@ -113,6 +109,29 @@ export class SubmitApprovedSignatureMsg extends Message { } } +export class RejectSignatureMsg extends Message { + public static type(): MessageType { + return MessageType.RejectSignature; + } + + constructor(public readonly msgId: string) { + super(); + } + + validate(): void { + validateProps(this, ["msgId"]); + return; + } + + route(): string { + return ROUTE; + } + + type(): string { + return RejectSignatureMsg.type(); + } +} + export class ConnectInterfaceResponseMsg extends Message { public static type(): MessageType { return MessageType.ConnectInterfaceResponse; @@ -127,17 +146,11 @@ export class ConnectInterfaceResponseMsg extends Message { } validate(): void { - if (typeof this.interfaceTabId === "undefined") { - throw new Error("interfaceTabId not set"); - } - - if (!this.interfaceOrigin) { - throw new Error("interfaceOrigin not set"); - } - - if (typeof this.allowConnection === "undefined") { - throw new Error("allowConnection not set"); - } + validateProps(this, [ + "interfaceTabId", + "interfaceOrigin", + "allowConnection", + ]); } route(): string { @@ -159,9 +172,7 @@ export class RevokeConnectionMsg extends Message { } validate(): void { - if (typeof this.originToRevoke === "undefined") { - throw new Error("originToRevoke not set"); - } + validateProps(this, ["originToRevoke"]); } route(): string { diff --git a/apps/extension/src/background/approvals/service.test.ts b/apps/extension/src/background/approvals/service.test.ts index 7c4108bd6..f8a6681e4 100644 --- a/apps/extension/src/background/approvals/service.test.ts +++ b/apps/extension/src/background/approvals/service.test.ts @@ -264,19 +264,14 @@ describe("approvals service", () => { jest.spyOn(service as any, "_launchApprovalWindow"); try { - const res = await service.approveTx(type, "", "", AccountType.Mnemonic); + const res = await service.approveTx( + type, + [{ specificMsg: "", txMsg: "" }], + AccountType.Mnemonic + ); expect(res).toBeUndefined(); } catch (e) {} }); - - it("should throw an error if txType is not found", async () => { - const type: any = 999; - jest.spyOn(borsh, "deserialize").mockReturnValue({}); - - await expect( - service.approveTx(type, "", "", AccountType.Mnemonic) - ).rejects.toBeDefined(); - }); }); describe("getParamsTransfer", () => { @@ -431,6 +426,7 @@ describe("approvals service", () => { amount: bondMsgValue.amount.toString(), publicKey: txMsgValue.publicKey, nativeToken: bondMsgValue.nativeToken, + validator: bondMsgValue.validator, }); }); }); @@ -461,6 +457,7 @@ describe("approvals service", () => { expect(params).toEqual({ source: unbondMsgValue.source, amount: unbondMsgValue.amount.toString(), + validator: unbondMsgValue.validator, publicKey: txMsgValue.publicKey, nativeToken: txMsgValue.token, }); @@ -557,8 +554,12 @@ describe("approvals service", () => { jest.spyOn(service["txStore"], "get").mockImplementation(() => { return Promise.resolve({ txType, - txMsg, - specificMsg, + tx: [ + { + txMsg, + specificMsg, + }, + ], }); }); @@ -583,8 +584,12 @@ describe("approvals service", () => { jest.spyOn(service["txStore"], "get").mockImplementation(() => { return Promise.resolve({ txType, - txMsg, - specificMsg, + tx: [ + { + txMsg, + specificMsg, + }, + ], }); }); diff --git a/apps/extension/src/background/approvals/service.ts b/apps/extension/src/background/approvals/service.ts index 8db9ab7aa..3d8235d7a 100644 --- a/apps/extension/src/background/approvals/service.ts +++ b/apps/extension/src/background/approvals/service.ts @@ -26,13 +26,24 @@ import { LedgerService } from "background/ledger"; import { VaultService } from "background/vault"; import { ExtensionBroadcaster } from "extension"; import { LocalStorage } from "storage"; -import { TxStore } from "./types"; +import { PendingTx, PendingTxDetails, TxStore } from "./types"; type GetParams = ( specificMsg: Uint8Array, txDetails: TxMsgValue ) => Record; +const getParamsMethod = (txType: SupportedTx): GetParams => + txType === TxType.Bond ? ApprovalsService.getParamsBond + : txType === TxType.Unbond ? ApprovalsService.getParamsUnbond + : txType === TxType.Withdraw ? ApprovalsService.getParamsWithdraw + : txType === TxType.Transfer ? ApprovalsService.getParamsTransfer + : txType === TxType.IBCTransfer ? ApprovalsService.getParamsIbcTransfer + : txType === TxType.EthBridgeTransfer ? + ApprovalsService.getParamsEthBridgeTransfer + : txType === TxType.VoteProposal ? ApprovalsService.getParamsVoteProposal + : assertNever(txType); + export class ApprovalsService { // holds promises which can be resolved with a message from a pop-up window protected resolverMap: Record< @@ -52,6 +63,25 @@ export class ApprovalsService { protected readonly broadcaster: ExtensionBroadcaster ) {} + async queryPendingTx(msgId: string): Promise { + const storedTx = await this.txStore.get(msgId); + + if (!storedTx) { + throw new Error("Pending tx not found!"); + } + + const { txType } = storedTx; + + return storedTx.tx.map((pendingTx: PendingTx) => { + const { specificMsg, txMsg } = pendingTx; + const specificMsgBuffer = Buffer.from(fromBase64(specificMsg)); + const txMsgBuffer = Buffer.from(fromBase64(txMsg)); + const txDetails = deserialize(txMsgBuffer, TxMsgValue); + const details = getParamsMethod(txType)(specificMsgBuffer, txDetails); + return details; + }); + } + async approveSignature( signer: string, data: string @@ -122,39 +152,15 @@ export class ApprovalsService { async approveTx( txType: SupportedTx, - txMsg: string, - specificMsg: string, + tx: PendingTx[], type: AccountType ): Promise { const msgId = uuid(); - await this.txStore.set(msgId, { txType, txMsg, specificMsg }); - - // Decode tx details and launch approval screen - const txMsgBuffer = Buffer.from(fromBase64(txMsg)); - const txDetails = deserialize(txMsgBuffer, TxMsgValue); - - const specificMsgBuffer = Buffer.from(fromBase64(specificMsg)); + await this.txStore.set(msgId, { txType, tx }); - const getParams = - txType === TxType.Bond ? ApprovalsService.getParamsBond - : txType === TxType.Unbond ? ApprovalsService.getParamsUnbond - : txType === TxType.Withdraw ? ApprovalsService.getParamsWithdraw - : txType === TxType.Transfer ? ApprovalsService.getParamsTransfer - : txType === TxType.IBCTransfer ? ApprovalsService.getParamsIbcTransfer - : txType === TxType.EthBridgeTransfer ? - ApprovalsService.getParamsEthBridgeTransfer - : txType === TxType.VoteProposal ? ApprovalsService.getParamsVoteProposal - : assertNever(txType); - - const baseUrl = `${browser.runtime.getURL( + const url = `${browser.runtime.getURL( "approvals.html" - )}#/approve-tx/${txType}`; - - const url = paramsToUrl(baseUrl, { - ...getParams(specificMsgBuffer, txDetails), - msgId, - accountType: type, - }); + )}#/approve-tx/${msgId}/${txType}/${type}`; await this._launchApprovalWindow(url); } @@ -234,6 +240,7 @@ export class ApprovalsService { source, nativeToken: tokenAddress, amount: amountBN, + validator, } = specificDetails; const amount = new BigNumber(amountBN.toString()); @@ -245,13 +252,14 @@ export class ApprovalsService { amount: amount.toString(), publicKey, nativeToken: tokenAddress, + validator, }; }; static getParamsUnbond: GetParams = (specificMsg, txDetails) => { const specificDetails = deserialize(specificMsg, UnbondMsgValue); - const { source, amount: amountBN } = specificDetails; + const { source, amount: amountBN, validator } = specificDetails; const amount = new BigNumber(amountBN.toString()); const { publicKey, nativeToken } = ApprovalsService.getTxDetails(txDetails); @@ -261,6 +269,7 @@ export class ApprovalsService { amount: amount.toString(), publicKey, nativeToken, + validator, }; }; @@ -302,26 +311,33 @@ export class ApprovalsService { // Authenticate keyring and submit approved transaction from storage async submitTx(msgId: string): Promise { // Fetch pending transfer tx - const tx = await this.txStore.get(msgId); + const storedTx = await this.txStore.get(msgId); - if (!tx) { + if (!storedTx) { throw new Error("Pending tx not found!"); } - const { txType, specificMsg, txMsg } = tx; - - const submitFn = - txType === TxType.Bond ? this.keyRingService.submitBond - : txType === TxType.Unbond ? this.keyRingService.submitUnbond - : txType === TxType.Transfer ? this.keyRingService.submitTransfer - : txType === TxType.IBCTransfer ? this.keyRingService.submitIbcTransfer - : txType === TxType.EthBridgeTransfer ? - this.keyRingService.submitEthBridgeTransfer - : txType === TxType.Withdraw ? this.keyRingService.submitWithdraw - : txType === TxType.VoteProposal ? this.keyRingService.submitVoteProposal - : assertNever(txType); - - await submitFn.call(this.keyRingService, specificMsg, txMsg, msgId); + const { txType } = storedTx; + + await Promise.all( + storedTx.tx.map(async (pendingTx: PendingTx) => { + const { specificMsg, txMsg } = pendingTx; + const submitFn = + txType === TxType.Bond ? this.keyRingService.submitBond + : txType === TxType.Unbond ? this.keyRingService.submitUnbond + : txType === TxType.Transfer ? this.keyRingService.submitTransfer + : txType === TxType.IBCTransfer ? + this.keyRingService.submitIbcTransfer + : txType === TxType.EthBridgeTransfer ? + this.keyRingService.submitEthBridgeTransfer + : txType === TxType.Withdraw ? this.keyRingService.submitWithdraw + : txType === TxType.VoteProposal ? + this.keyRingService.submitVoteProposal + : assertNever(txType); + + await submitFn.call(this.keyRingService, specificMsg, txMsg, msgId); + }) + ); return await this._clearPendingTx(msgId); } diff --git a/apps/extension/src/background/approvals/types.ts b/apps/extension/src/background/approvals/types.ts index 1d2b20107..40ad2887f 100644 --- a/apps/extension/src/background/approvals/types.ts +++ b/apps/extension/src/background/approvals/types.ts @@ -2,8 +2,15 @@ import { SupportedTx } from "@heliax/namada-sdk/web"; export type ApprovedOriginsStore = string[]; -export type TxStore = { - txType: SupportedTx; +export type PendingTx = { txMsg: string; specificMsg: string; }; + +export type TxStore = { + txType: SupportedTx; + tx: PendingTx[]; +}; + +// TODO: Add specific types here! +export type PendingTxDetails = Record; diff --git a/apps/extension/src/background/ledger/handler.ts b/apps/extension/src/background/ledger/handler.ts index a76c87f85..dedd80ca3 100644 --- a/apps/extension/src/background/ledger/handler.ts +++ b/apps/extension/src/background/ledger/handler.ts @@ -1,13 +1,13 @@ -import { Handler, Env, Message, InternalHandler } from "router"; -import { LedgerService } from "./service"; +import { Env, Handler, InternalHandler, Message } from "router"; import { - GetTxBytesMsg, - SubmitSignedRevealPKMsg, GetRevealPKBytesMsg, - SubmitSignedTxMsg, + GetTxBytesMsg, QueryStoredPK, StoreRevealedPK, + SubmitSignedRevealPKMsg, + SubmitSignedTxMsg, } from "./messages"; +import { LedgerService } from "./service"; export const getHandler: (service: LedgerService) => Handler = (service) => { return (env: Env, msg: Message) => { @@ -15,7 +15,10 @@ export const getHandler: (service: LedgerService) => Handler = (service) => { case GetTxBytesMsg: return handleGetTxBytesMsg(service)(env, msg as GetTxBytesMsg); case GetRevealPKBytesMsg: - return handleGetRevealPKBytesMsg(service)(env, msg as GetTxBytesMsg); + return handleGetRevealPKBytesMsg(service)( + env, + msg as GetRevealPKBytesMsg + ); case SubmitSignedRevealPKMsg: return handleSubmitSignedRevealPKMsg(service)( env, diff --git a/apps/extension/src/background/ledger/messages.ts b/apps/extension/src/background/ledger/messages.ts index 570e1d46f..080bc215f 100644 --- a/apps/extension/src/background/ledger/messages.ts +++ b/apps/extension/src/background/ledger/messages.ts @@ -12,10 +12,12 @@ enum MessageType { StoreRevealedPK = "store-revealed-pk", } -export class GetTxBytesMsg extends Message<{ - bytes: Uint8Array; - path: string; -}> { +export class GetTxBytesMsg extends Message< + { + bytes: Uint8Array; + path: string; + }[] +> { public static type(): MessageType { return MessageType.GetTxBytes; } diff --git a/apps/extension/src/background/ledger/service.ts b/apps/extension/src/background/ledger/service.ts index a0df7e767..e91ff8d24 100644 --- a/apps/extension/src/background/ledger/service.ts +++ b/apps/extension/src/background/ledger/service.ts @@ -6,7 +6,7 @@ import { TxType, makeBip44Path } from "@heliax/namada-sdk/web"; import { chains } from "@namada/chains"; import { KVStore } from "@namada/storage"; import { AccountType, TxMsgValue } from "@namada/types"; -import { TxStore } from "background/approvals"; +import { PendingTx, TxStore } from "background/approvals"; import { KeyRingService } from "background/keyring"; import { SdkService } from "background/sdk"; import { ExtensionBroadcaster, ExtensionRequester } from "extension"; @@ -23,7 +23,7 @@ export class LedgerService { protected readonly revealedPKStorage: RevealedPKStorage, protected readonly requester: ExtensionRequester, protected readonly broadcaster: ExtensionBroadcaster - ) {} + ) { } async getRevealPKBytes( txMsg: string @@ -96,89 +96,94 @@ export class LedgerService { bytes: string, signatures: ResponseSign ): Promise { - const storeResult = await this.txStore.get(msgId); + const storedTx = await this.txStore.get(msgId); - if (!storeResult) { + if (!storedTx) { throw new Error(`Transaction ${msgId} not found!`); } - const { txMsg } = storeResult; + storedTx.tx.forEach(async (pendingTx: PendingTx) => { + const { txMsg } = pendingTx; - await this.broadcaster.startTx(msgId, txType); - const sdk = this.sdkService.getSdk(); - try { - const signedTxBytes = sdk.tx.appendSignature( - fromBase64(bytes), - signatures - ); - const signedTx = { - txMsg: fromBase64(txMsg), - tx: signedTxBytes, - }; - const innerTxHash = await sdk.rpc.broadcastTx(signedTx); - - // Clear pending tx if successful - await this.txStore.set(msgId, null); - - // Broadcast update events - await this.broadcaster.completeTx(msgId, txType, true, innerTxHash); - await this.broadcaster.updateBalance(); - - if ([TxType.Bond, TxType.Unbond, TxType.Withdraw].includes(txType)) { - await this.broadcaster.updateStaking(); + await this.broadcaster.startTx(msgId, txType); + const sdk = this.sdkService.getSdk(); + try { + const signedTxBytes = sdk.tx.appendSignature( + fromBase64(bytes), + signatures + ); + const signedTx = { + txMsg: fromBase64(txMsg), + tx: signedTxBytes, + }; + const innerTxHash = await sdk.rpc.broadcastTx(signedTx); + + // Clear pending tx if successful + await this.txStore.set(msgId, null); + + // Broadcast update events + await this.broadcaster.completeTx(msgId, txType, true, innerTxHash); + await this.broadcaster.updateBalance(); + + if ([TxType.Bond, TxType.Unbond, TxType.Withdraw].includes(txType)) { + await this.broadcaster.updateStaking(); + } + } catch (e) { + console.warn(e); + await this.broadcaster.completeTx(msgId, txType, false, `${e}`); } - } catch (e) { - console.warn(e); - await this.broadcaster.completeTx(msgId, txType, false, `${e}`); - } + }); } async getTxBytes( txType: TxType, msgId: string, address: string - ): Promise<{ bytes: Uint8Array; path: string }> { - const storeResult = await this.txStore.get(msgId); + ): Promise<{ bytes: Uint8Array; path: string }[]> { + const storedTx = await this.txStore.get(msgId); - if (!storeResult) { + if (!storedTx) { console.warn(`txMsg not found for msgId: ${msgId}`); throw new Error(`Transfer Transaction ${msgId} not found!`); } - const { txMsg, specificMsg } = storeResult; - const { coinType } = chains.namada.bip44; - try { - // Query account from Ledger storage to determine path for signer - const account = await this.keyringService.findByAddress(address); - - if (!account) { - throw new Error(`Ledger account not found for ${address}`); - } - - if (!account.publicKey) { - throw new Error(`Ledger account missing public key for ${address}`); - } - - if (account.type !== AccountType.Ledger) { - throw new Error(`Ledger account not found for ${address}`); - } - - const sdk = this.sdkService.getSdk(); - const builtTx = await sdk.tx.buildTxFromSerializedArgs( - txType, - fromBase64(specificMsg), - fromBase64(txMsg), - account.publicKey - ); - const path = makeBip44Path(coinType, account.path); - - return { bytes: builtTx.toBytes(), path }; - } catch (e) { - console.warn(e); - throw new Error(`${e}`); - } + return Promise.all( + storedTx.tx.map(async (pendingTx: PendingTx) => { + const { txMsg, specificMsg } = pendingTx; + try { + // Query account from Ledger storage to determine path for signer + const account = await this.keyringService.findByAddress(address); + + if (!account) { + throw new Error(`Ledger account not found for ${address}`); + } + + if (!account.publicKey) { + throw new Error(`Ledger account missing public key for ${address}`); + } + + if (account.type !== AccountType.Ledger) { + throw new Error(`Ledger account not found for ${address}`); + } + + const sdk = this.sdkService.getSdk(); + const builtTx = await sdk.tx.buildTxFromSerializedArgs( + txType, + fromBase64(specificMsg), + fromBase64(txMsg), + account.publicKey + ); + const path = makeBip44Path(coinType, account.path); + + return { bytes: builtTx.toBytes(), path }; + } catch (e) { + console.warn(e); + throw new Error(`${e}`); + } + }) + ); } async queryStoredRevealedPK(publicKey: string): Promise { diff --git a/apps/extension/src/provider/Namada.ts b/apps/extension/src/provider/Namada.ts index 7344ee527..c80d36b62 100644 --- a/apps/extension/src/provider/Namada.ts +++ b/apps/extension/src/provider/Namada.ts @@ -136,7 +136,7 @@ export class Namada implements INamada { public async submitTx(props: TxMsgProps): Promise { return await this.requester?.sendMessage( Ports.Background, - new ApproveTxMsg(props.txType, props.specificMsg, props.txMsg, props.type) + new ApproveTxMsg(props.txType, props.tx, props.type) ); } diff --git a/apps/extension/src/provider/Signer.ts b/apps/extension/src/provider/Signer.ts index 3694710bf..5acb94f1d 100644 --- a/apps/extension/src/provider/Signer.ts +++ b/apps/extension/src/provider/Signer.ts @@ -80,22 +80,28 @@ export class Signer implements ISigner { private async submitTx( txType: SupportedTx, constructor: new (args: Args) => T, - args: Args, + args: Args | Args[], txArgs: TxProps, type: AccountType ): Promise { - const msgValue = new constructor(args); - const msg = new Message(); - const encoded = msg.encode(msgValue); + const tx = (args instanceof Array ? args : [args]).map((arg) => { + const msgValue = new constructor(arg); + const msg = new Message(); + const encoded = msg.encode(msgValue); - const txMsgValue = new TxMsgValue(txArgs); - const txMsg = new Message(); - const txEncoded = txMsg.encode(txMsgValue); + const txMsgValue = new TxMsgValue(txArgs); + const txMsg = new Message(); + const txEncoded = txMsg.encode(txMsgValue); + + return { + specificMsg: toBase64(encoded), + txMsg: toBase64(txEncoded), + }; + }); return await this._namada.submitTx({ txType, - specificMsg: toBase64(encoded), - txMsg: toBase64(txEncoded), + tx, type, }); } @@ -104,7 +110,7 @@ export class Signer implements ISigner { * Submit bond transaction */ public async submitBond( - args: BondProps, + args: BondProps | BondProps[], txArgs: TxProps, type: AccountType ): Promise { @@ -115,7 +121,7 @@ export class Signer implements ISigner { * Submit unbond transaction */ public async submitUnbond( - args: UnbondProps, + args: UnbondProps | UnbondProps[], txArgs: TxProps, type: AccountType ): Promise { @@ -126,7 +132,7 @@ export class Signer implements ISigner { * Submit withdraw transaction */ public async submitWithdraw( - args: WithdrawProps, + args: WithdrawProps | WithdrawProps[], txArgs: TxProps, type: AccountType ): Promise { @@ -137,7 +143,7 @@ export class Signer implements ISigner { * Submit vote proposal transaction */ public async submitVoteProposal( - args: VoteProposalProps, + args: VoteProposalProps | VoteProposalProps[], txArgs: TxProps, type: AccountType ): Promise { @@ -154,7 +160,7 @@ export class Signer implements ISigner { * Submit a transfer */ public async submitTransfer( - args: TransferProps, + args: TransferProps | TransferProps[], txArgs: TxProps, type: AccountType ): Promise { @@ -165,7 +171,7 @@ export class Signer implements ISigner { * Submit an ibc transfer */ public async submitIbcTransfer( - args: IbcTransferProps, + args: IbcTransferProps | IbcTransferProps[], txArgs: TxProps, type: AccountType ): Promise { @@ -182,7 +188,7 @@ export class Signer implements ISigner { * Submit an eth bridge transfer */ public async submitEthBridgeTransfer( - args: EthBridgeTransferProps, + args: EthBridgeTransferProps | EthBridgeTransferProps[], txArgs: TxProps, type: AccountType ): Promise { diff --git a/apps/extension/src/provider/messages.ts b/apps/extension/src/provider/messages.ts index e4e99b2f0..375c06619 100644 --- a/apps/extension/src/provider/messages.ts +++ b/apps/extension/src/provider/messages.ts @@ -5,6 +5,7 @@ import { DerivedAccount, SignatureResponse, } from "@namada/types"; +import { PendingTx } from "background/approvals"; import { Message } from "router"; import { validateProps } from "utils"; @@ -219,15 +220,14 @@ export class ApproveTxMsg extends Message { constructor( public readonly txType: SupportedTx, - public readonly txMsg: string, - public readonly specificMsg: string, + public readonly tx: PendingTx[], public readonly accountType: AccountType ) { super(); } validate(): void { - validateProps(this, ["txType", "txMsg", "specificMsg", "accountType"]); + validateProps(this, ["txType", "tx", "accountType"]); } route(): string { diff --git a/apps/extension/src/utils/index.ts b/apps/extension/src/utils/index.ts index 960379731..e269f8f85 100644 --- a/apps/extension/src/utils/index.ts +++ b/apps/extension/src/utils/index.ts @@ -1,9 +1,7 @@ import { v5 as uuid } from "uuid"; import browser from "webextension-polyfill"; -import { Message, SignatureMsgValue, TxMsgValue, TxProps } from "@namada/types"; import { Result } from "@namada/utils"; -import { ISignature } from "@zondax/ledger-namada"; /** * Query the current extension tab and close it @@ -37,45 +35,9 @@ export const generateId = ( return uuid(args.join(":"), namespace); }; -/** - * Convert ISignature into serialized and encoded signature - */ -export const encodeSignature = (sig: ISignature): Uint8Array => { - const { - pubkey, - raw_indices, - raw_signature, - wrapper_indices, - wrapper_signature, - } = sig; - - /* eslint-disable */ - const props = { - pubkey: new Uint8Array((pubkey as any).data), - rawIndices: new Uint8Array((raw_indices as any).data), - rawSignature: new Uint8Array((raw_signature as any).data), - wrapperIndices: new Uint8Array((wrapper_indices as any).data), - wrapperSignature: new Uint8Array((wrapper_signature as any).data), - }; - /* eslint-enable */ - - const value = new SignatureMsgValue(props); - const msg = new Message(); - return msg.encode(value); -}; - -/** - * Helper to encode Tx given TxProps - */ -export const encodeTx = (tx: TxProps): Uint8Array => { - const txMsgValue = new TxMsgValue(tx); - const msg = new Message(); - return msg.encode(txMsgValue); -}; - export const validateProps = (object: T, props: (keyof T)[]): void => { props.forEach((prop) => { - if (!object[prop]) { + if (typeof object[prop] === "undefined") { throw new Error(`${String(prop)} was not provided!`); } }); @@ -91,14 +53,13 @@ export type PrivateKeyError = export const validatePrivateKey = ( privateKey: string ): Result => - privateKey.length > PRIVATE_KEY_MAX_LENGTH - ? Result.err({ t: "TooLong", maxLength: PRIVATE_KEY_MAX_LENGTH }) - : !/^[0-9a-f]*$/.test(privateKey) - ? Result.err({ t: "BadCharacter" }) + privateKey.length > PRIVATE_KEY_MAX_LENGTH ? + Result.err({ t: "TooLong", maxLength: PRIVATE_KEY_MAX_LENGTH }) + : !/^[0-9a-f]*$/.test(privateKey) ? Result.err({ t: "BadCharacter" }) : Result.ok(null); // Remove prefix from private key, which may be present when exporting keys from CLI export const filterPrivateKeyPrefix = (privateKey: string): string => - privateKey.length === PRIVATE_KEY_MAX_LENGTH + 2 - ? privateKey.replace(/^00/, "") + privateKey.length === PRIVATE_KEY_MAX_LENGTH + 2 ? + privateKey.replace(/^00/, "") : privateKey; diff --git a/apps/namada-interface/src/slices/StakingAndGovernance/actions.ts b/apps/namada-interface/src/slices/StakingAndGovernance/actions.ts index 0c742fb02..825d2cc05 100644 --- a/apps/namada-interface/src/slices/StakingAndGovernance/actions.ts +++ b/apps/namada-interface/src/slices/StakingAndGovernance/actions.ts @@ -26,7 +26,7 @@ import { const { NAMADA_INTERFACE_NAMADA_TOKEN: - tokenAddress = "tnam1qxgfw7myv4dh0qna4hq0xdg6lx77fzl7dcem8h7e", + tokenAddress = "tnam1qxgfw7myv4dh0qna4hq0xdg6lx77fzl7dcem8h7e", } = process.env; const toValidator = (address: string): Validator => ({ @@ -53,9 +53,9 @@ const toMyValidators = ( index == -1 ? (arr: MyValidators[]) => arr : (arr: MyValidators[], idx: number) => [ - ...arr.slice(0, idx), - ...arr.slice(idx + 1), - ]; + ...arr.slice(0, idx), + ...arr.slice(idx + 1), + ]; const stakedAmount = new BigNumber(stake).plus( new BigNumber(v?.stakedAmount || 0) @@ -260,12 +260,13 @@ export const postNewBonding = createAsyncThunk< const { type, publicKey } = account.details; await signer.submitBond( - { + // TODO: Interface should allow multiple Bond Tx + [{ source, validator, amount: new BigNumber(amount), nativeToken: nativeToken || tokenAddress, - }, + }], { token: nativeToken || tokenAddress, feeAmount: gasPrice, @@ -310,11 +311,12 @@ export const postNewUnbonding = createAsyncThunk< } = derived[id][source]; await signer.submitUnbond( - { + // TODO: Interface should allow multiple Unbond Tx + [{ source, validator, amount: new BigNumber(amount), - }, + }], { token: nativeToken || tokenAddress, feeAmount: gasPrice, @@ -353,10 +355,11 @@ export const postNewWithdraw = createAsyncThunk< } = derived[id][owner]; await signer.submitWithdraw( - { + // TODO: Interface should allow multiple Withdraw Tx + [{ source: owner, validator: validatorId, - }, + }], { token: nativeToken || tokenAddress, feeAmount: gasPrice, diff --git a/packages/integrations/src/Namada.ts b/packages/integrations/src/Namada.ts index ea1ba1563..25b2b81f6 100644 --- a/packages/integrations/src/Namada.ts +++ b/packages/integrations/src/Namada.ts @@ -15,7 +15,7 @@ import { BridgeProps, Integration } from "./types/Integration"; export default class Namada implements Integration { private _namada: WindowWithNamada["namada"] | undefined; - constructor(public readonly chain: Chain) {} + constructor(public readonly chain: Chain) { } public get instance(): INamada | undefined { return this._namada; diff --git a/packages/types/src/namada.ts b/packages/types/src/namada.ts index 3c23bad47..d45becf68 100644 --- a/packages/types/src/namada.ts +++ b/packages/types/src/namada.ts @@ -6,8 +6,10 @@ export type TxMsgProps = { //TODO: figure out if we can make it better // eslint-disable-next-line @typescript-eslint/no-explicit-any txType: any; - specificMsg: string; - txMsg: string; + tx: { + specificMsg: string; + txMsg: string; + }[]; type: AccountType; }; diff --git a/packages/types/src/signer.ts b/packages/types/src/signer.ts index 52052968b..47a6d8938 100644 --- a/packages/types/src/signer.ts +++ b/packages/types/src/signer.ts @@ -24,37 +24,37 @@ export interface Signer { ) => Promise; verify: (publicKey: string, hash: string, signature: string) => Promise; submitBond( - args: BondProps, + args: BondProps | BondProps[], txArgs: TxProps, type: AccountType ): Promise; submitUnbond( - args: UnbondProps, + args: UnbondProps | UnbondProps[], txArgs: TxProps, type: AccountType ): Promise; submitWithdraw( - args: WithdrawProps, + args: WithdrawProps | WithdrawProps[], txArgs: TxProps, type: AccountType ): Promise; submitTransfer( - args: TransferProps, + args: TransferProps | TransferProps[], txArgs: TxProps, type: AccountType ): Promise; submitIbcTransfer( - args: IbcTransferProps, + args: IbcTransferProps | IbcTransferProps[], txArgs: TxProps, type: AccountType ): Promise; submitVoteProposal( - args: VoteProposalProps, + args: VoteProposalProps | VoteProposalProps[], txArgs: TxProps, type: AccountType ): Promise; submitEthBridgeTransfer( - args: EthBridgeTransferProps, + args: EthBridgeTransferProps | EthBridgeTransferProps[], txArgs: TxProps, type: AccountType ): Promise;