diff --git a/client/HubApi.ts b/client/HubApi.ts index 957e64f3..201110fd 100644 --- a/client/HubApi.ts +++ b/client/HubApi.ts @@ -33,6 +33,8 @@ import { AddBtcAddressesRequest, AddBtcAddressesResult, SignPolygonTransactionRequest, + SignPolygonGaslessTransferRequest, + SignedPolygonGaslessTransfer, SignedPolygonTransaction, Cashlink, CashlinkState, @@ -196,11 +198,17 @@ export default class HubApi< return this._request(requestBehavior, RequestType.SIGN_BTC_TRANSACTION, [request]); } - public signPolygonTransaction( - request: Promise | SignPolygonTransactionRequest, + public signPolygonTransaction< + R extends SignPolygonTransactionRequest | SignPolygonGaslessTransferRequest, + B extends BehaviorType = DB, + >( + request: Promise | R, requestBehavior: RequestBehavior = this._defaultBehavior as any, - ): Promise { - return this._request(requestBehavior, RequestType.SIGN_POLYGON_TRANSACTION, [request]); + ): Promise { + // The result type depends on the request variant, which the generic result mapping cannot express + return this._request(requestBehavior, RequestType.SIGN_POLYGON_TRANSACTION, [request]) as any; } public setupSwap( diff --git a/client/PublicRequestTypes.ts b/client/PublicRequestTypes.ts index c21b1d5d..604719f2 100644 --- a/client/PublicRequestTypes.ts +++ b/client/PublicRequestTypes.ts @@ -792,6 +792,10 @@ export interface AddBtcAddressesResult { * Polygon */ +/** + * A transaction relayed via OpenGSN, used for swaps: HTLC redeem and refund, and the USDC.e to USDC conversion. + * Plain transfers use SignPolygonGaslessTransferRequest. + */ export interface SignPolygonTransactionRequest extends BasicRequest, RelayRequest { recipientLabel?: string; /** @@ -830,6 +834,58 @@ export interface SignedPolygonTransaction { signature: string; // HEX } +/** + * A gasless USDC or USDT0 transfer on Polygon through the GaslessTransfer contract and the Nimiq relay, + * see https://github.com/NimiqToolbox/gas-abstraction. Amounts are in the token's smallest unit (6 decimals). + */ +export interface PolygonGaslessTransfer { + token: PolygonAddress; + from: PolygonAddress; + to: PolygonAddress; + amount: IntString; + fee: IntString; // The relay's fee quote + relay: PolygonAddress; // The relay address of the fee quote +} + +/** + * A signed transfer intent. `nonce` identifies the payment: every version of a payment keeps it, so that at most one + * version can execute. + */ +export interface PolygonGaslessTransferRequest extends PolygonGaslessTransfer { + nonce: HexString; // bytes32 + deadline: IntString; // Unix seconds +} + +export interface SignPolygonGaslessTransferRequest extends BasicRequest { + request: PolygonGaslessTransfer; + /** + * The sender's current permit nonce in the token contract (`nonces(from)`). + */ + tokenNonce: number; + /** + * The payment's latest signed version, when signing a new version of an existing payment, for example with a + * higher fee. The new version keeps the payment's nonce, token and recipient. Only request this after checking + * on-chain that the payment's nonce is still unused. Without it, a new payment with a fresh nonce is signed. + */ + corrects?: PolygonGaslessTransferRequest; + recipientLabel?: string; +} + +/** + * The signed transfer in the shape of the relay's POST /v1/transfer body. It contains a token permit, so do not + * persist or log it. + */ +export interface SignedPolygonGaslessTransfer { + request: PolygonGaslessTransferRequest; + signature: HexString; + authorization: { + mode: 'permit', + v: number, + r: HexString, + s: HexString, + }; +} + export type RpcRequest = SignTransactionRequest | SignMultisigTransactionRequest | SignStakingRequest @@ -846,6 +902,7 @@ export type RpcRequest = SignTransactionRequest | SignBtcTransactionRequest | AddBtcAddressesRequest | SignPolygonTransactionRequest + | SignPolygonGaslessTransferRequest | SetupSwapRequest | RefundSwapRequest | ConnectAccountRequest; @@ -865,6 +922,7 @@ export type RpcResult = SignedTransaction | SignedBtcTransaction | AddBtcAddressesResult | SignedPolygonTransaction + | SignedPolygonGaslessTransfer | SetupSwapResult | ConnectedAccount; @@ -886,7 +944,7 @@ export type ResultByRequestType = T extends RequestType.EXPORT ? ExportResult : T extends RequestType.CREATE_CASHLINK | RequestType.MANAGE_CASHLINK ? Cashlink : T extends RequestType.SIGN_BTC_TRANSACTION ? SignedBtcTransaction : - T extends RequestType.SIGN_POLYGON_TRANSACTION ? SignedPolygonTransaction : + T extends RequestType.SIGN_POLYGON_TRANSACTION ? SignedPolygonTransaction | SignedPolygonGaslessTransfer : T extends RequestType.ACTIVATE_BITCOIN ? Account : T extends RequestType.ACTIVATE_POLYGON ? Account : T extends RequestType.ADD_BTC_ADDRESSES ? AddBtcAddressesResult : diff --git a/src/lib/RequestParser.ts b/src/lib/RequestParser.ts index 51a07ed5..89502f0f 100644 --- a/src/lib/RequestParser.ts +++ b/src/lib/RequestParser.ts @@ -25,6 +25,7 @@ import type { MultiCurrencyCheckoutRequest, SignBtcTransactionRequest, SignPolygonTransactionRequest, + SignPolygonGaslessTransferRequest, SetupSwapRequest, RefundSwapRequest, SignMultisigTransactionRequest, @@ -56,6 +57,10 @@ import { ParsedBitcoinDirectPaymentOptions } from './paymentOptions/BitcoinPayme import { Utf8Tools } from '@nimiq/utils'; import Config from 'config'; import { parseSignTransactionRequest, rawSignTransactionRequest } from './SignTransactionRequestParsing'; +import { + parseSignPolygonGaslessTransferRequest, + rawSignPolygonGaslessTransferRequest, +} from './SignPolygonTransactionRequestParsing'; import { SwapAsset } from '@nimiq/fastspot-api'; import RpcApi from './RpcApi'; @@ -540,8 +545,14 @@ export class RequestParser { }; return parsedSignBtcTransactionRequest; case RequestType.SIGN_POLYGON_TRANSACTION: - const signPolygonTransactionRequest = request as SignPolygonTransactionRequest; + const signPolygonTransactionRequest = request as + SignPolygonTransactionRequest | SignPolygonGaslessTransferRequest; + + if (!('relayData' in signPolygonTransactionRequest)) { + return parseSignPolygonGaslessTransferRequest(signPolygonTransactionRequest); + } + // Swap related transactions relayed via OpenGSN, validated by the Keyguard return { kind: RequestType.SIGN_POLYGON_TRANSACTION, ...signPolygonTransactionRequest, @@ -985,6 +996,9 @@ export class RequestParser { } as SignBtcTransactionRequest; case RequestType.SIGN_POLYGON_TRANSACTION: const signPolygonTransactionRequest = request as ParsedSignPolygonTransactionRequest; + if (!('relayData' in signPolygonTransactionRequest)) { + return rawSignPolygonGaslessTransferRequest(signPolygonTransactionRequest); + } return { ...signPolygonTransactionRequest, } as SignPolygonTransactionRequest; diff --git a/src/lib/RequestTypes.ts b/src/lib/RequestTypes.ts index f846c6ff..7708eb43 100644 --- a/src/lib/RequestTypes.ts +++ b/src/lib/RequestTypes.ts @@ -1,4 +1,12 @@ -import type { Currency, PaymentType, RequestType, CashlinkTheme, RelayRequest } from '../../client/PublicRequestTypes'; +import type { + Currency, + PaymentType, + RequestType, + CashlinkTheme, + RelayRequest, + PolygonGaslessTransfer, + PolygonGaslessTransferRequest, +} from '../../client/PublicRequestTypes'; import type { ParsedPaymentOptions } from './paymentOptions/ParsedPaymentOptions'; import type { ParsedNimiqSpecifics, ParsedNimiqDirectPaymentOptions } from './paymentOptions/NimiqPaymentOptions'; import type { ParsedEtherSpecifics, ParsedEtherDirectPaymentOptions } from './paymentOptions/EtherPaymentOptions'; @@ -223,7 +231,19 @@ export interface ParsedAddBtcAddressesRequest extends ParsedSimpleRequest { * Polygon */ -export interface ParsedSignPolygonTransactionRequest extends ParsedBasicRequest, RelayRequest { +export interface ParsedSignPolygonGaslessTransferRequest extends ParsedBasicRequest { + request: PolygonGaslessTransfer; + tokenNonce: number; + corrects?: PolygonGaslessTransferRequest; + recipientLabel?: string; +} + +export type ParsedSignPolygonTransactionRequest = + ParsedSignPolygonOpenGsnTransactionRequest + | ParsedSignPolygonGaslessTransferRequest; + +/** Swap related transactions (HTLC redeem and refund, USDC.e conversion), relayed via OpenGSN. */ +export interface ParsedSignPolygonOpenGsnTransactionRequest extends ParsedBasicRequest, RelayRequest { recipientLabel?: string; approval?: { tokenNonce: number, diff --git a/src/lib/RpcApi.ts b/src/lib/RpcApi.ts index 032758f0..22442575 100644 --- a/src/lib/RpcApi.ts +++ b/src/lib/RpcApi.ts @@ -69,8 +69,8 @@ export default class RpcApi { RequestType.MANAGE_CASHLINK, RequestType.CONNECT_ACCOUNT, // Request for sending a gas-abstracted USDC or USDT transaction on Polygon, without the need for the user to - // hold Polygon. See sendPolygonTransaction in hub.ts in github.com/nimiq/wallet for how to build the request - // data. + // hold POL. See sendPolygonTransaction in hub.ts and lib/usdc/Gasless.ts in github.com/nimiq/wallet for how to + // build the request data for a gasless transfer. RequestType.SIGN_POLYGON_TRANSACTION, // While SIGN_TRANSACTION and SIGN_POLYGON_TRANSACTION are whitelisted, SIGN_BTC_TRANSACTION is not, as it // identifies the account via its internal accountId, not by sender. Additionally, SIGN_BTC_TRANSACTION is not diff --git a/src/lib/SignPolygonTransactionRequestParsing.ts b/src/lib/SignPolygonTransactionRequestParsing.ts new file mode 100644 index 00000000..5463018b --- /dev/null +++ b/src/lib/SignPolygonTransactionRequestParsing.ts @@ -0,0 +1,78 @@ +import { RequestType } from '../../client/PublicRequestTypes'; +import type { PolygonGaslessTransfer, SignPolygonGaslessTransferRequest } from '../../client/PublicRequestTypes'; +import type { ParsedSignPolygonGaslessTransferRequest } from './RequestTypes'; + +/** + * Parsing of SIGN_POLYGON_TRANSACTION requests for gasless USDC/USDT0 transfers and their inverse mapping for the + * history state. Requests relayed via OpenGSN (swaps) are passed through by the RequestParser, as before. + * + * Only the shape is checked here. The Keyguard validates the transfer against its own pins (contract, relays, tokens) + * and fee limit, and builds the typed data it signs itself. The Hub forwards only the known plain fields. + * + * This module is deliberately kept free of imports of the app graph (RpcApi, router, views), such that unit tests can + * import it standalone. + */ + +export function parseSignPolygonGaslessTransferRequest( + request: SignPolygonGaslessTransferRequest, +): ParsedSignPolygonGaslessTransferRequest { + const transfer = parsePolygonGaslessTransfer(request.request, 'request'); + + const tokenNonce = request.tokenNonce; + if (typeof tokenNonce !== 'number' || !Number.isSafeInteger(tokenNonce) || tokenNonce < 0) { + throw new Error('tokenNonce must be a non-negative integer'); + } + + let corrects: ParsedSignPolygonGaslessTransferRequest['corrects']; + if (request.corrects !== undefined) { + const { nonce, deadline } = request.corrects; + if (typeof nonce !== 'string' || !/^0x[0-9a-fA-F]{64}$/.test(nonce)) { + throw new Error('corrects.nonce must be a bytes32 hex string'); + } + if (typeof deadline !== 'string' || !/^\d+$/.test(deadline)) { + throw new Error('corrects.deadline must be a decimal string'); + } + corrects = { + ...parsePolygonGaslessTransfer(request.corrects, 'corrects'), + nonce, + deadline, + }; + } + + return { + kind: RequestType.SIGN_POLYGON_TRANSACTION, + appName: request.appName, + request: transfer, + tokenNonce, + corrects, + recipientLabel: request.recipientLabel, + }; +} + +export function rawSignPolygonGaslessTransferRequest( + request: ParsedSignPolygonGaslessTransferRequest, +): SignPolygonGaslessTransferRequest { + return { + appName: request.appName, + request: request.request, + tokenNonce: request.tokenNonce, + corrects: request.corrects, + recipientLabel: request.recipientLabel, + }; +} + +function parsePolygonGaslessTransfer(transfer: unknown, name: string): PolygonGaslessTransfer { + if (!transfer || typeof transfer !== 'object') throw new Error(`${name} must be an object`); + const { token, from, to, amount, fee, relay } = transfer as PolygonGaslessTransfer; + for (const [field, value] of Object.entries({ token, from, to, relay })) { + if (typeof value !== 'string' || !/^0x[0-9a-fA-F]{40}$/.test(value)) { + throw new Error(`${name}.${field} must be a Polygon address`); + } + } + for (const [field, value] of Object.entries({ amount, fee })) { + if (typeof value !== 'string' || !/^\d+$/.test(value)) { + throw new Error(`${name}.${field} must be a decimal string`); + } + } + return { token, from, to, amount, fee, relay }; +} diff --git a/src/store.ts b/src/store.ts index 2e7235b0..add94f07 100644 --- a/src/store.ts +++ b/src/store.ts @@ -126,7 +126,9 @@ const store: StoreOptions = { })); }, findWalletByPolygonAddress: (state) => (address: string): WalletInfo | undefined => { - return state.wallets.find((wallet) => wallet.polygonAddresses.some((ai) => ai.address === address)); + address = address.toLowerCase(); + return state.wallets.find((wallet) => wallet.polygonAddresses.some( + (ai) => ai.address.toLowerCase() === address)); }, findWalletByKeyId: (state) => (keyId: string): WalletInfo | undefined => { return state.wallets.find((wallet) => wallet.keyId === keyId); diff --git a/src/views/SignPolygonTransaction.vue b/src/views/SignPolygonTransaction.vue index cfecec0a..a9d48dc4 100644 --- a/src/views/SignPolygonTransaction.vue +++ b/src/views/SignPolygonTransaction.vue @@ -23,7 +23,8 @@ export default class SignPolygonTransaction extends Vue { if (typeof this.request.request.from === 'string') { // existence checked in RpcApi const senderAccount = this.findWalletByPolygonAddress(this.request.request.from)!; - const signer = senderAccount.polygonAddresses.find((ai) => ai.address === this.request.request.from)!; + const from = this.request.request.from.toLowerCase(); + const signer = senderAccount.polygonAddresses.find((ai) => ai.address.toLowerCase() === from)!; keyId = senderAccount.keyId; keyPath = signer.path; @@ -36,11 +37,22 @@ export default class SignPolygonTransaction extends Vue { } = this.request.request.from); } - const request: KeyguardClient.SignPolygonTransactionRequest = { + const request: KeyguardClient.SignPolygonTransactionRequest | KeyguardClient.SignPolygonGaslessTransferRequest + = 'relayData' in this.request ? { ...this.request, keyId, keyPath, keyLabel, + } : { + // Gasless transfer: forward only the known fields + appName: this.request.appName, + keyId, + keyPath, + keyLabel, + request: this.request.request, + tokenNonce: this.request.tokenNonce, + corrects: this.request.corrects, + recipientLabel: this.request.recipientLabel, }; staticStore.keyguardRequest = request; diff --git a/src/views/SignPolygonTransactionSuccess.vue b/src/views/SignPolygonTransactionSuccess.vue index 18bbe10c..97749ee4 100644 --- a/src/views/SignPolygonTransactionSuccess.vue +++ b/src/views/SignPolygonTransactionSuccess.vue @@ -2,18 +2,24 @@