Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions client/HubApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ import {
AddBtcAddressesRequest,
AddBtcAddressesResult,
SignPolygonTransactionRequest,
SignPolygonGaslessTransferRequest,
SignedPolygonGaslessTransfer,
SignedPolygonTransaction,
Cashlink,
CashlinkState,
Expand Down Expand Up @@ -196,11 +198,17 @@ export default class HubApi<
return this._request(requestBehavior, RequestType.SIGN_BTC_TRANSACTION, [request]);
}

public signPolygonTransaction<B extends BehaviorType = DB>(
request: Promise<SignPolygonTransactionRequest> | SignPolygonTransactionRequest,
public signPolygonTransaction<
R extends SignPolygonTransactionRequest | SignPolygonGaslessTransferRequest,
B extends BehaviorType = DB,
>(
request: Promise<R> | R,
requestBehavior: RequestBehavior<B> = this._defaultBehavior as any,
): Promise<B extends BehaviorType.REDIRECT ? void : SignedPolygonTransaction> {
return this._request(requestBehavior, RequestType.SIGN_POLYGON_TRANSACTION, [request]);
): Promise<B extends BehaviorType.REDIRECT
? void
: R extends SignPolygonGaslessTransferRequest ? SignedPolygonGaslessTransfer : SignedPolygonTransaction> {
// 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<B extends BehaviorType = DB>(
Expand Down
60 changes: 59 additions & 1 deletion client/PublicRequestTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/**
Expand Down Expand Up @@ -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
Expand All @@ -846,6 +902,7 @@ export type RpcRequest = SignTransactionRequest
| SignBtcTransactionRequest
| AddBtcAddressesRequest
| SignPolygonTransactionRequest
| SignPolygonGaslessTransferRequest
| SetupSwapRequest
| RefundSwapRequest
| ConnectAccountRequest;
Expand All @@ -865,6 +922,7 @@ export type RpcResult = SignedTransaction
| SignedBtcTransaction
| AddBtcAddressesResult
| SignedPolygonTransaction
| SignedPolygonGaslessTransfer
| SetupSwapResult
| ConnectedAccount;

Expand All @@ -886,7 +944,7 @@ export type ResultByRequestType<T> =
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 :
Expand Down
16 changes: 15 additions & 1 deletion src/lib/RequestParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import type {
MultiCurrencyCheckoutRequest,
SignBtcTransactionRequest,
SignPolygonTransactionRequest,
SignPolygonGaslessTransferRequest,
SetupSwapRequest,
RefundSwapRequest,
SignMultisigTransactionRequest,
Expand Down Expand Up @@ -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';

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
24 changes: 22 additions & 2 deletions src/lib/RequestTypes.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions src/lib/RpcApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
78 changes: 78 additions & 0 deletions src/lib/SignPolygonTransactionRequestParsing.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
4 changes: 3 additions & 1 deletion src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,9 @@ const store: StoreOptions<RootState> = {
}));
},
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);
Expand Down
16 changes: 14 additions & 2 deletions src/views/SignPolygonTransaction.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
12 changes: 9 additions & 3 deletions src/views/SignPolygonTransactionSuccess.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,24 @@

<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import { SignedPolygonTransaction } from '../../client/PublicRequestTypes';
import { SignedPolygonTransaction, SignedPolygonGaslessTransfer } from '../../client/PublicRequestTypes';
import { State } from 'vuex-class';
import KeyguardClient from '@nimiq/keyguard-client';

@Component
export default class SignPolygonTransactionSuccess extends Vue {
@State private keyguardResult!: KeyguardClient.SignedPolygonTransaction;
@State private keyguardResult!:
KeyguardClient.SignedPolygonTransaction
| KeyguardClient.SignedPolygonGaslessTransfer;

private async mounted() {
const result: SignedPolygonTransaction = {
const result: SignedPolygonTransaction | SignedPolygonGaslessTransfer = 'message' in this.keyguardResult ? {
message: this.keyguardResult.message,
signature: this.keyguardResult.signature,
} : {
request: this.keyguardResult.request,
signature: this.keyguardResult.signature,
authorization: this.keyguardResult.authorization,
};

this.$rpc.resolve(result);
Expand Down
Loading
Loading