diff --git a/client/src/PublicRequest.ts b/client/src/PublicRequest.ts index fb0ae14ef..c21a23609 100644 --- a/client/src/PublicRequest.ts +++ b/client/src/PublicRequest.ts @@ -257,7 +257,14 @@ export type SepaSettlementInstruction = { }, }; -export type SettlementInstruction = MockSettlementInstruction | SepaSettlementInstruction; +export type SinpeMovilSettlementInstruction = { + type: 'sinpemovil', + contractId: string, + phoneNumber: string, +}; + +export type SettlementInstruction = + MockSettlementInstruction | SepaSettlementInstruction | SinpeMovilSettlementInstruction; export type SignSwapRequestLayout = 'standard' | 'slider'; @@ -299,6 +306,13 @@ export type SignSwapRequestCommon = SimpleRequest & { // bankLogoUrl?: string, // bankColor?: string, } + ) | ( + {type: 'CRC'} + & { + amount: number, + fee: number, + senderLabel?: string, + } ), redeem: ( {type: 'NIM'} @@ -345,6 +359,17 @@ export type SignSwapRequestCommon = SimpleRequest & { // bankLogoUrl?: string, // bankColor?: string, } + ) | ( + { type: 'CRC' } + & { + keyPath: string, + // A SettlementInstruction contains a `type`, so cannot be in the + // root of the object (it conflicts with the 'CRC' type). + settlement: Omit, + amount: number, + fee: number, + recipientLabel?: string, + } ), // Data needed for display @@ -394,7 +419,7 @@ export type SignSwapRequestSlider = SignSwapRequestCommon & { export type SignSwapRequest = SignSwapRequestStandard | SignSwapRequestSlider; export type SignSwapResult = SimpleResult & { - eurPubKey?: string, + fiatPubKey?: string, tmpCookieEncryptionKey?: Uint8Array; }; @@ -411,7 +436,7 @@ export type SignSwapTransactionsRequest = { type: 'USDC_MATIC', htlcData: string, } | { - type: 'EUR', + type: 'EUR' | 'CRC', hash: string, timeout: number, htlcId: string, @@ -431,7 +456,7 @@ export type SignSwapTransactionsRequest = { timeout: number, htlcId: string, } | { - type: 'EUR', + type: 'EUR' | 'CRC', hash: string, timeout: number, htlcId: string, @@ -531,6 +556,7 @@ export type SignSwapTransactionsResult = { btc?: SignedBitcoinTransaction, usdc?: SignedPolygonTransaction, eur?: string, // When funding EUR: empty string, when redeeming EUR: JWS of the settlement instructions + crc?: string, // When funding CRC: empty string, when redeeming CRC: JWS of the settlement instructions refundTx?: string, }; diff --git a/src/assets/icons/sinpe-movil.svg b/src/assets/icons/sinpe-movil.svg new file mode 100644 index 000000000..875977018 --- /dev/null +++ b/src/assets/icons/sinpe-movil.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/common.css b/src/common.css index e960977b5..417392350 100644 --- a/src/common.css +++ b/src/common.css @@ -349,6 +349,10 @@ body.loading .page:target .page-footer > .loading-spinner { content: "EUR"; } +.crc-symbol::before { + content: "CRC"; +} + .address { font-family: "Fira Mono", "Andale Mono", monospace; font-size: 1.625rem; diff --git a/src/components/BalanceDistributionBar.js b/src/components/BalanceDistributionBar.js index 6d4d70c21..ce31e28bb 100644 --- a/src/components/BalanceDistributionBar.js +++ b/src/components/BalanceDistributionBar.js @@ -3,7 +3,7 @@ /* global CryptoUtils */ /** @typedef {{address: string, balance: number, active: boolean, newBalance: number}} Segment */ -/** @typedef {'NIM' | 'BTC' | 'USDC_MATIC' | 'EUR'} Asset */ +/** @typedef {'NIM' | 'BTC' | 'USDC_MATIC' | 'EUR' | 'CRC'} Asset */ class BalanceDistributionBar { // eslint-disable-line no-unused-vars /** diff --git a/src/components/SwapFeesTooltip.js b/src/components/SwapFeesTooltip.js index 2c14e48ec..d7806207a 100644 --- a/src/components/SwapFeesTooltip.js +++ b/src/components/SwapFeesTooltip.js @@ -37,8 +37,8 @@ class SwapFeesTooltip { // eslint-disable-line no-unused-vars if (fundTx.type === 'BTC' || redeemTx.type === 'BTC') { const myFee = fundTx.type === 'BTC' ? fundTx.inputs.reduce((sum, input) => sum + input.witnessUtxo.value, 0) - - fundTx.recipientOutput.value - - (fundTx.changeOutput ? fundTx.changeOutput.value : 0) + - fundTx.recipientOutput.value + - (fundTx.changeOutput ? fundTx.changeOutput.value : 0) : redeemTx.type === 'BTC' ? redeemTx.input.witnessUtxo.value - redeemTx.output.value : 0; @@ -75,22 +75,28 @@ class SwapFeesTooltip { // eslint-disable-line no-unused-vars } // Show OASIS fees next - if (fundTx.type === 'EUR' || redeemTx.type === 'EUR') { - const myFee = fundTx.type === 'EUR' + if (fundTx.type === 'EUR' || fundTx.type === 'CRC' || redeemTx.type === 'EUR' || redeemTx.type === 'CRC') { + const myFee = fundTx.type === 'EUR' || fundTx.type === 'CRC' ? fundTx.fee - : redeemTx.type === 'EUR' + : redeemTx.type === 'EUR' || redeemTx.type === 'CRC' ? redeemTx.fee : 0; - const theirFee = fundTx.type === 'EUR' ? fundFees.processing : redeemFees.processing; + const theirFee = fundTx.type === 'EUR' || fundTx.type === 'CRC' + ? fundFees.processing + : redeemFees.processing; - const fiatRate = fundTx.type === 'EUR' ? fundingFiatRate : redeemingFiatRate; - const fiatFee = CryptoUtils.unitsToCoins('EUR', myFee + theirFee) * fiatRate; + const fiatRate = fundTx.type === 'EUR' || fundTx.type === 'CRC' ? fundingFiatRate : redeemingFiatRate; + const fiatSwapAsset = (fundTx.type === 'EUR' || fundTx.type === 'CRC' ? fundTx.type : redeemTx.type); + const fiatFee = CryptoUtils.unitsToCoins(fiatSwapAsset, myFee + theirFee) * fiatRate; const rows = this._createOasisLine( fiatFee, fiatCurrency, - (myFee + theirFee) / (fundTx.type === 'EUR' ? exchangeFromAmount : exchangeToAmount), + (myFee + theirFee) / (fundTx.type === 'EUR' || fundTx.type === 'CRC' + ? exchangeFromAmount + : exchangeToAmount + ), ); this.$tooltip.appendChild(rows[0]); this.$tooltip.appendChild(rows[1]); diff --git a/src/lib/NumberFormatting.js b/src/lib/NumberFormatting.js index 163a52a32..530049514 100644 --- a/src/lib/NumberFormatting.js +++ b/src/lib/NumberFormatting.js @@ -105,6 +105,7 @@ class NumberFormatting { // eslint-disable-line no-unused-vars case 'eur': case 'chf': return 'de'; + case 'crc': case 'gbp': case 'usd': return 'en'; diff --git a/src/lib/RequestParser.js b/src/lib/RequestParser.js index d0563b8c4..77dab375d 100644 --- a/src/lib/RequestParser.js +++ b/src/lib/RequestParser.js @@ -158,10 +158,10 @@ class RequestParser { // eslint-disable-line no-unused-vars throw new Errors.InvalidRequestError('Data must not exceed 64 bytes'); } if (flags === Nimiq.Transaction.Flag.CONTRACT_CREATION - && data.byteLength !== 78 // HTLC - && data.byteLength !== 24 // Vesting - && data.byteLength !== 36 // Vesting - && data.byteLength !== 44) { // Vesting + && data.byteLength !== 78 // HTLC + && data.byteLength !== 24 // Vesting + && data.byteLength !== 36 // Vesting + && data.byteLength !== 44) { // Vesting throw new Errors.InvalidRequestError( 'Contract creation data must be 78 bytes for HTLC and 24, 36, or 44 bytes for vesting contracts', ); @@ -359,4 +359,45 @@ class RequestParser { // eslint-disable-line no-unused-vars } return parsedUrl; } + + /** + * Parses and validates a phone number. + * @param {string} phoneNumber - The phone number to parse. Should be in E.164 format. + * @param {object} [options] - Parsing options + * @param {boolean} [options.required] - Whether the phone number is required + * @param {string[]} [options.expectedCountryCodes] - Allowed country codes. + * @returns {string|undefined} - The formatted phone number or undefined + */ + parsePhoneNumber(phoneNumber, options = {}) { + const { required = false, expectedCountryCodes = [] } = options; + + if (phoneNumber === undefined && !required) return undefined; + + if (typeof phoneNumber !== 'string') { + throw new Errors.InvalidRequestError('The phone number must be a string'); + } + + // If it contains spaces, it's not a valid number + if (phoneNumber.includes(' ')) { + throw new Errors.InvalidRequestError('The phone number must not contain spaces'); + } + + // Remove all non-digit characters + const digitsOnly = phoneNumber.replace(/\D/g, ''); + + // Check if the number has a valid length (assuming international format) + if (digitsOnly.length < 7 || digitsOnly.length > 15) { + throw new Errors.InvalidRequestError('The phone number has an invalid length'); + } + + if (expectedCountryCodes.length > 0) { + const hasValidCountryCode = expectedCountryCodes.some(countryCode => phoneNumber.startsWith(countryCode)); + if (!hasValidCountryCode) { + throw new Errors.InvalidRequestError('The phone number has an invalid country code'); + } + } + + // Format the number (simple E.164 format) + return `+${digitsOnly}`; + } } diff --git a/src/lib/crc/CrcConstants.js b/src/lib/crc/CrcConstants.js new file mode 100644 index 000000000..82d5fe7a6 --- /dev/null +++ b/src/lib/crc/CrcConstants.js @@ -0,0 +1,3 @@ +const CrcConstants = { // eslint-disable-line no-unused-vars + CENTS_PER_COIN: 100, +}; diff --git a/src/lib/crc/CrcUtils.js b/src/lib/crc/CrcUtils.js new file mode 100644 index 000000000..023bbde6c --- /dev/null +++ b/src/lib/crc/CrcUtils.js @@ -0,0 +1,19 @@ +/* global CrcConstants */ + +class CrcUtils { // eslint-disable-line no-unused-vars + /** + * @param {number} coins CRC amount in decimal + * @returns {number} Number of CRC cents + */ + static coinsToCents(coins) { + return Math.round(coins * CrcConstants.CENTS_PER_COIN); + } + + /** + * @param {number} cents Number of CRC cents + * @returns {number} CRC count in decimal + */ + static centsToCoins(cents) { + return cents / CrcConstants.CENTS_PER_COIN; + } +} diff --git a/src/lib/swap/CryptoUtils.js b/src/lib/swap/CryptoUtils.js index 9742092d5..e5d66d220 100644 --- a/src/lib/swap/CryptoUtils.js +++ b/src/lib/swap/CryptoUtils.js @@ -5,10 +5,12 @@ /* global PolygonUtils */ /* global EuroConstants */ /* global EuroUtils */ +/* global CrcConstants */ +/* global CrcUtils */ class CryptoUtils { // eslint-disable-line no-unused-vars /** - * @param {'NIM' | 'BTC' | 'USDC_MATIC' | 'EUR'} asset + * @param {'NIM' | 'BTC' | 'USDC_MATIC' | 'EUR' | 'CRC'} asset * @param {number} units * @returns {number} */ @@ -18,12 +20,13 @@ class CryptoUtils { // eslint-disable-line no-unused-vars case 'BTC': return BitcoinUtils.satoshisToCoins(units); case 'USDC_MATIC': return PolygonUtils.unitsToCoins(units); case 'EUR': return EuroUtils.centsToCoins(units); + case 'CRC': return CrcUtils.centsToCoins(units); default: throw new Error(`Invalid asset ${asset}`); } } /** - * @param {'NIM' | 'BTC' | 'USDC_MATIC' | 'EUR'} asset + * @param {'NIM' | 'BTC' | 'USDC_MATIC' | 'EUR' | 'CRC'} asset * @returns {number} */ static assetDecimals(asset) { @@ -32,13 +35,14 @@ class CryptoUtils { // eslint-disable-line no-unused-vars case 'BTC': return Math.log10(BitcoinConstants.SATOSHIS_PER_COIN); case 'USDC_MATIC': return Math.log10(PolygonConstants.UNITS_PER_COIN); case 'EUR': return Math.log10(EuroConstants.CENTS_PER_COIN); + case 'CRC': return Math.log10(CrcConstants.CENTS_PER_COIN); default: throw new Error(`Invalid asset ${asset}`); } } /** - * @param {'NIM' | 'BTC' | 'USDC_MATIC' | 'EUR'} asset - * @returns {'nim' | 'btc' | 'usdc' | 'eur'} + * @param {'NIM' | 'BTC' | 'USDC_MATIC' | 'EUR' | 'CRC'} asset + * @returns {'nim' | 'btc' | 'usdc' | 'eur' | 'crc'} */ static assetToCurrency(asset) { switch (asset) { @@ -46,6 +50,7 @@ class CryptoUtils { // eslint-disable-line no-unused-vars case 'BTC': return 'btc'; case 'USDC_MATIC': return 'usdc'; case 'EUR': return 'eur'; + case 'CRC': return 'crc'; default: throw new Error(`Invalid asset ${asset}`); } } diff --git a/src/request/sign-swap/SignSwap.css b/src/request/sign-swap/SignSwap.css index 9542d3273..17116bc67 100644 --- a/src/request/sign-swap/SignSwap.css +++ b/src/request/sign-swap/SignSwap.css @@ -5,7 +5,8 @@ .nim-symbol, .btc-symbol, .usdc-symbol, -.eur-symbol { +.eur-symbol, +.crc-symbol { margin-left: 0.25em; } @@ -101,6 +102,7 @@ } .layout-standard .account.eur .identicon, +.layout-standard .account.crc .identicon, .layout-standard .account.btc .identicon { padding: .25rem; } diff --git a/src/request/sign-swap/SignSwap.js b/src/request/sign-swap/SignSwap.js index 3c6897653..3071d3c26 100644 --- a/src/request/sign-swap/SignSwap.js +++ b/src/request/sign-swap/SignSwap.js @@ -85,7 +85,9 @@ class SignSwap { - (fundTx.changeOutput ? fundTx.changeOutput.value : 0); break; case 'USDC_MATIC': swapFromValue = fundTx.description.args.amount .add(fundTx.description.args.fee).toNumber(); break; - case 'EUR': swapFromValue = fundTx.amount + fundTx.fee; break; + case 'CRC': + case 'EUR': + swapFromValue = fundTx.amount + fundTx.fee; break; default: throw new Errors.KeyguardError('Invalid asset'); } @@ -95,7 +97,9 @@ class SignSwap { case 'NIM': swapToValue = redeemTx.transaction.value; break; case 'BTC': swapToValue = redeemTx.output.value; break; case 'USDC_MATIC': swapToValue = redeemTx.amount; break; - case 'EUR': swapToValue = redeemTx.amount - redeemTx.fee; break; + case 'CRC': + case 'EUR': + swapToValue = redeemTx.amount - redeemTx.fee; break; default: throw new Errors.KeyguardError('Invalid asset'); } @@ -112,24 +116,24 @@ class SignSwap { $swapLeftValue.textContent = NumberFormatting.formatNumber( CryptoUtils.unitsToCoins(leftAsset, leftAmount), leftAsset === 'USDC_MATIC' ? 2 : CryptoUtils.assetDecimals(leftAsset), - leftAsset === 'EUR' || leftAsset === 'USDC_MATIC' ? 2 : 0, + leftAsset === 'EUR' || leftAsset === 'CRC' || leftAsset === 'USDC_MATIC' ? 2 : 0, ); $swapRightValue.textContent = NumberFormatting.formatNumber( CryptoUtils.unitsToCoins(rightAsset, rightAmount), rightAsset === 'USDC_MATIC' ? 2 : CryptoUtils.assetDecimals(rightAsset), - rightAsset === 'EUR' || rightAsset === 'USDC_MATIC' ? 2 : 0, + rightAsset === 'EUR' || rightAsset === 'CRC' || rightAsset === 'USDC_MATIC' ? 2 : 0, ); $swapValues.classList.add( `${CryptoUtils.assetToCurrency(fundTx.type)}-to-${CryptoUtils.assetToCurrency(redeemTx.type)}`, ); - /** @type {'NIM' | 'BTC' | 'USDC_MATIC' | 'EUR'} */ + /** @type {'NIM' | 'BTC' | 'USDC_MATIC' | 'EUR' | 'CRC'} */ let exchangeBaseAsset; // If EUR is part of the swap, the other currency is the base asset - if (fundTx.type === 'EUR') exchangeBaseAsset = redeemTx.type; - else if (redeemTx.type === 'EUR') exchangeBaseAsset = fundTx.type; + if (fundTx.type === 'EUR' || fundTx.type === 'CRC') exchangeBaseAsset = redeemTx.type; + else if (redeemTx.type === 'EUR' || redeemTx.type === 'CRC') exchangeBaseAsset = fundTx.type; // If the layout is 'slider', the left asset is the base asset else if (request.layout === SignSwapApi.Layouts.SLIDER) exchangeBaseAsset = leftAsset; else exchangeBaseAsset = fundTx.type; @@ -164,7 +168,8 @@ class SignSwap { const exchangeRateString = `1 ${exchangeBaseAsset} = ${NumberFormatting.formatNumber( exchangeRate, exchangeRateDecimals, - exchangeOtherAsset === 'EUR' ? CryptoUtils.assetDecimals(exchangeOtherAsset) : 0, + exchangeOtherAsset === 'EUR' || exchangeOtherAsset === 'CRC' + ? CryptoUtils.assetDecimals(exchangeOtherAsset) : 0, )} ${exchangeOtherAsset}`; /** @type {HTMLDivElement} */ @@ -204,6 +209,10 @@ class SignSwap { } else if (request.fund.type === 'EUR') { $leftIdenticon.innerHTML = TemplateTags.hasVars(0)``; $leftLabel.textContent = request.fund.bankLabel || I18n.translatePhrase('sign-swap-your-bank'); + } else if (request.fund.type === 'CRC') { + $leftIdenticon.innerHTML = TemplateTags + .hasVars(0)``; + $leftLabel.textContent = request.fund.senderLabel || 'Sinpe Móvil'; } if (request.redeem.type === 'NIM') { @@ -227,6 +236,10 @@ class SignSwap { } $rightLabel.textContent = label; + } else if (request.redeem.type === 'CRC') { + $rightIdenticon.innerHTML = TemplateTags + .hasVars(0)``; + $rightLabel.textContent = request.redeem.recipientLabel || 'Sinpe Móvil'; } } @@ -427,7 +440,7 @@ class SignSwap { } /** - * @param {'NIM' | 'BTC' | 'USDC_MATIC' | 'EUR'} asset + * @param {'NIM' | 'BTC' | 'USDC_MATIC' | 'EUR' | 'CRC'} asset * @param {Parsed} request * @returns {number} */ @@ -464,10 +477,11 @@ class SignSwap { // is the transaction value + tx fee. ? redeemTx.amount + redeemTx.description.args.fee.toNumber() + request.redeemFees.funding : 0; // Should never happen, if parsing works correctly + case 'CRC': case 'EUR': - return fundTx.type === 'EUR' + return fundTx.type === 'EUR' || fundTx.type === 'CRC' ? fundTx.amount - request.fundFees.redeeming - : redeemTx.type === 'EUR' + : redeemTx.type === 'EUR' || redeemTx.type === 'CRC' ? redeemTx.amount + request.redeemFees.processing + request.redeemFees.funding : 0; // Should never happen, if parsing works correctly default: @@ -507,7 +521,7 @@ class SignSwap { const bitcoinKey = new BitcoinKey(key); const polygonKey = new PolygonKey(key); - /** @type {{nim: string, btc: string[], usdc: string, eur: string, btc_refund?: string}} */ + /** @type {{nim: string, btc: string[], usdc: string, crc: string, eur: string, btc_refund?: string}} */ const privateKeys = {}; if (request.fund.type === 'NIM') { @@ -582,7 +596,7 @@ class SignSwap { privateKeys.usdc = wallet.privateKey; } - if (request.fund.type === 'EUR') { + if (request.fund.type === 'EUR' || request.fund.type === 'CRC') { // No signature required } @@ -612,7 +626,7 @@ class SignSwap { } /** @type {string | undefined} */ - let eurPubKey; + let fiatPubKey; if (request.redeem.type === 'EUR') { const privateKey = key.derivePrivateKey(request.redeem.keyPath); @@ -620,7 +634,16 @@ class SignSwap { // Public key of EUR signing key is required as the contract recipient // when confirming a swap to Fastspot from the Hub. - eurPubKey = Nimiq.PublicKey.derive(privateKey).toHex(); + fiatPubKey = Nimiq.PublicKey.derive(privateKey).toHex(); + } + + if (request.redeem.type === 'CRC') { + const privateKey = key.derivePrivateKey(request.redeem.keyPath); + privateKeys.crc = privateKey.toHex(); + + // Public key of CRC signing key is required as the contract recipient + // when confirming a swap to Fastspot from the Hub. + fiatPubKey = Nimiq.PublicKey.derive(privateKey).toHex(); } try { @@ -650,7 +673,7 @@ class SignSwap { resolve({ success: true, - eurPubKey, + fiatPubKey, // The Hub will get access to the encryption key, but not the encrypted cookie. The server can // potentially get access to the encrypted cookie, but not the encryption key (the result including diff --git a/src/request/sign-swap/SignSwapApi.js b/src/request/sign-swap/SignSwapApi.js index 368c03953..3de84d5d5 100644 --- a/src/request/sign-swap/SignSwapApi.js +++ b/src/request/sign-swap/SignSwapApi.js @@ -7,6 +7,7 @@ /* global Iban */ /* global ethers */ /* global CONFIG */ +/* global Constants */ /* global PolygonContractABIs */ class SignSwapApi extends PolygonRequestParserMixin(BitcoinRequestParserMixin(TopLevelApi)) { @@ -34,6 +35,12 @@ class SignSwapApi extends PolygonRequestParserMixin(BitcoinRequestParserMixin(To throw new Errors.InvalidRequestError('Swap must be between two different currencies'); } + const swapToFiat = request.redeem.type === 'EUR' || request.redeem.type === 'CRC'; + const swapFromFiat = request.fund.type === 'EUR' || request.fund.type === 'CRC'; + if (swapToFiat && swapFromFiat) { + throw new Errors.InvalidRequestError('Swaps between fiat currencies are not supported'); + } + if (request.fund.type === 'NIM') { parsedRequest.fund = { type: 'NIM', @@ -95,6 +102,13 @@ class SignSwapApi extends PolygonRequestParserMixin(BitcoinRequestParserMixin(To fee: this.parsePositiveInteger(request.fund.fee, true, 'fund.fee'), bankLabel: this.parseLabel(request.fund.bankLabel, true, 'fund.bankLabel'), }; + } else if (request.fund.type === 'CRC') { + parsedRequest.fund = { + type: 'CRC', + amount: this.parsePositiveInteger(request.fund.amount, false, 'fund.amount'), + fee: this.parsePositiveInteger(request.fund.fee, true, 'fund.fee'), + senderLabel: this.parseLabel(request.fund.senderLabel, true, 'fund.recipientLabel'), + }; } else { throw new Errors.InvalidRequestError('Invalid funding type'); } @@ -145,14 +159,37 @@ class SignSwapApi extends PolygonRequestParserMixin(BitcoinRequestParserMixin(To amount: this.parsePositiveInteger(request.redeem.amount, false, 'redeem.amount'), }; } else if (request.redeem.type === 'EUR') { + const settlement = this.parseOasisSettlementInstruction(request.redeem.settlement, 'redeem.settlement'); + if ( + settlement.type !== 'sepa' + && (CONFIG.NETWORK === Constants.NETWORK.MAIN || settlement.type !== 'mock') + ) { + throw new Errors.InvalidRequestError('Invalid redeeming settlement type'); + } parsedRequest.redeem = { type: 'EUR', keyPath: this.parsePath(request.redeem.keyPath, 'redeem.keyPath'), - settlement: this.parseOasisSettlementInstruction(request.redeem.settlement, 'redeem.settlement'), + settlement, amount: this.parsePositiveInteger(request.redeem.amount, false, 'redeem.amount'), fee: this.parsePositiveInteger(request.redeem.fee, true, 'redeem.fee'), bankLabel: this.parseLabel(request.redeem.bankLabel, true, 'redeem.bankLabel'), }; + } else if (request.redeem.type === 'CRC') { + const settlement = this.parseOasisSettlementInstruction(request.redeem.settlement, 'redeem.settlement'); + if ( + settlement.type !== 'sinpemovil' + && (CONFIG.NETWORK === Constants.NETWORK.MAIN || settlement.type !== 'mock') + ) { + throw new Errors.InvalidRequestError('Invalid redeeming settlement type'); + } + parsedRequest.redeem = { + type: 'CRC', + keyPath: this.parsePath(request.redeem.keyPath, 'redeem.keyPath'), + settlement, + amount: this.parsePositiveInteger(request.redeem.amount, false, 'redeem.amount'), + fee: this.parsePositiveInteger(request.redeem.fee, true, 'redeem.fee'), + recipientLabel: this.parseLabel(request.redeem.recipientLabel, true, 'redeem.recipientLabel'), + }; } else { throw new Errors.InvalidRequestError('Invalid redeeming type'); } @@ -336,9 +373,9 @@ class SignSwapApi extends PolygonRequestParserMixin(BitcoinRequestParserMixin(To * | PolygonRedeemDescription * | PolygonRedeemWithSecretInDataDescription} */ (usdcHtlcContract.interface.parseTransaction({ - data: forwardRequest.data, - value: forwardRequest.value, - })); + data: forwardRequest.data, + value: forwardRequest.value, + })); if (!allowedMethods.includes(description.name)) { throw new Errors.InvalidRequestError('Requested Polygon contract method is invalid'); @@ -369,12 +406,17 @@ class SignSwapApi extends PolygonRequestParserMixin(BitcoinRequestParserMixin(To return [forwardRequest, description]; } + /** + * @typedef {Omit} MockSettlementInstruction + * @typedef {Omit} SepaSettlementInstruction + * @typedef {Omit} SinpeMovilSettlementInstruction + */ + /** * Checks that the given instruction is a valid OASIS SettlementInstruction * @param {unknown} obj * @param {string} parameterName - * @returns {Omit | - * Omit} + * @returns {MockSettlementInstruction | SepaSettlementInstruction | SinpeMovilSettlementInstruction} */ parseOasisSettlementInstruction(obj, parameterName) { if (typeof obj !== 'object' || obj === null) { @@ -383,7 +425,7 @@ class SignSwapApi extends PolygonRequestParserMixin(BitcoinRequestParserMixin(To switch (/** @type {{type: unknown}} */ (obj).type) { case 'mock': { - /** @type {Omit} */ + /** @type {MockSettlementInstruction} */ const settlement = { type: 'mock', }; @@ -395,29 +437,42 @@ class SignSwapApi extends PolygonRequestParserMixin(BitcoinRequestParserMixin(To throw new Errors.InvalidRequestError('Invalid settlement recipient'); } - /** @type {Omit} */ + /** @type {SepaSettlementInstruction} */ const settlement = { type: 'sepa', recipient: { name: /** @type {string} */ ( this.parseLabel( - /** @type {{name: unknown}} */ (recipient).name, + /** @type {{name: unknown}} */(recipient).name, false, `${parameterName}.recipient.name`, ) ), iban: this.parseIban( - /** @type {{iban: unknown}} */ (recipient).iban, + /** @type {{iban: unknown}} */(recipient).iban, `${parameterName}.recipient.iban`, ), bic: this.parseBic( - /** @type {{bic: unknown}} */ (recipient).bic, + /** @type {{bic: unknown}} */(recipient).bic, `${parameterName}.recipient.bic`, ), }, }; return settlement; } + case 'sinpemovil': { + /** @type {SinpeMovilSettlementInstruction} */ + const settlement = { + type: 'sinpemovil', + phoneNumber: /** @type {string} */ ( + this.parsePhoneNumber( + /** @type {{phoneNumber: string}} */(obj).phoneNumber, + { expectedCountryCodes: ['+506'] }, + ) + ), + }; + return settlement; + } default: throw new Errors.InvalidRequestError('Invalid settlement type'); } } @@ -431,7 +486,7 @@ class SignSwapApi extends PolygonRequestParserMixin(BitcoinRequestParserMixin(To if (!Iban.isValid(iban)) { throw new Errors.InvalidRequestError(`${parameterName} is not a valid IBAN`); } - return Iban.printFormat(/** @type {string} */ (iban), ' '); + return Iban.printFormat(/** @type {string} */(iban), ' '); } get Handler() { diff --git a/src/request/sign-swap/index.html b/src/request/sign-swap/index.html index 2146c65e7..fc32eb6ea 100644 --- a/src/request/sign-swap/index.html +++ b/src/request/sign-swap/index.html @@ -65,6 +65,8 @@ + + diff --git a/src/request/swap-iframe/SwapIFrameApi.js b/src/request/swap-iframe/SwapIFrameApi.js index fcb605229..9d3b320f0 100644 --- a/src/request/swap-iframe/SwapIFrameApi.js +++ b/src/request/swap-iframe/SwapIFrameApi.js @@ -43,6 +43,7 @@ class SwapIFrameApi extends BitcoinRequestParserMixin(RequestParser) { // eslint * nim: string, * btc: string[], * usdc: string, + * crc: string, * eur: string, * btc_refund?: string, * }, request: any}} */ @@ -67,9 +68,12 @@ class SwapIFrameApi extends BitcoinRequestParserMixin(RequestParser) { // eslint if (privateKeys.usdc.length !== 66) throw new Error('Invalid USDC key stored in SessionStorage'); } - if (request.redeem.type === 'EUR') { - if (!privateKeys.eur) throw new Error('No EUR key stored in SessionStorage'); - if (privateKeys.eur.length !== 64) throw new Error('Invalid EUR key stored in SessionStorage'); + if (request.redeem.type === 'EUR' || request.redeem.type === 'CRC') { + /** @type { keyof typeof privateKeys } */ + // @ts-ignore The type of fiatKey is checked in the if clause + const fiatKey = request.redeem.type.toLocaleLowerCase(); + if (!privateKeys[fiatKey]) throw new Error(`No ${request.redeem.type} key stored in SessionStorage`); + if (privateKeys[fiatKey].length !== 64) throw new Error(`Invalid ${request.redeem.type} key stored in SessionStorage`); } // Deserialize stored request @@ -274,9 +278,10 @@ class SwapIFrameApi extends BitcoinRequestParserMixin(RequestParser) { // eslint }; } - if (request.fund.type === 'EUR' && storedRequest.fund.type === 'EUR') { + if ((request.fund.type === 'EUR' && storedRequest.fund.type === 'EUR') + || (request.fund.type === 'CRC' && storedRequest.fund.type === 'CRC')) { fund = { - type: 'EUR', + type: request.fund.type, htlcDetails: { hash: Nimiq.BufferUtils.toHex(Nimiq.BufferUtils.fromAny(request.fund.hash)), timeoutTimestamp: this.parsePositiveInteger(request.fund.timeout, false, 'fund.timeout'), @@ -285,9 +290,10 @@ class SwapIFrameApi extends BitcoinRequestParserMixin(RequestParser) { // eslint }; } - if (request.redeem.type === 'EUR' && storedRequest.redeem.type === 'EUR') { + if ((request.redeem.type === 'EUR' && storedRequest.redeem.type === 'EUR') + || (request.redeem.type === 'CRC' && storedRequest.redeem.type === 'CRC')) { redeem = { - type: 'EUR', + type: request.redeem.type, htlcDetails: { hash: Nimiq.BufferUtils.toHex(Nimiq.BufferUtils.fromAny(request.redeem.hash)), timeoutTimestamp: this.parsePositiveInteger(request.redeem.timeout, false, 'redeem.timeout'), @@ -413,7 +419,7 @@ class SwapIFrameApi extends BitcoinRequestParserMixin(RequestParser) { // eslint throw new Errors.KeyguardError('Missing address in funding change output'); } - outputs.push(/** @type {{address: string, value: number}} */ (storedRequest.fund.changeOutput)); + outputs.push(/** @type {{address: string, value: number}} */(storedRequest.fund.changeOutput)); } // Sort outputs by value ASC, then address ASC @@ -587,7 +593,7 @@ class SwapIFrameApi extends BitcoinRequestParserMixin(RequestParser) { // eslint const signature = await wallet._signTypedData( typedData.domain, - /** @type {Record} */ (/** @type {unknown} */ (cleanedTypes)), + /** @type {Record} */(/** @type {unknown} */ (cleanedTypes)), typedData.message, ); @@ -602,6 +608,11 @@ class SwapIFrameApi extends BitcoinRequestParserMixin(RequestParser) { // eslint result.eur = ''; } + if (parsedRequest.fund.type === 'CRC' && storedRequest.fund.type === 'CRC') { + // Nothing to do for funding sinpemovil + result.crc = ''; + } + if (parsedRequest.redeem.type === 'NIM' && storedRequest.redeem.type === 'NIM') { await loadNimiq(); @@ -731,7 +742,7 @@ class SwapIFrameApi extends BitcoinRequestParserMixin(RequestParser) { // eslint const signature = await wallet._signTypedData( typedData.domain, - /** @type {Record} */ (/** @type {unknown} */ (cleanedTypes)), + /** @type {Record} */(/** @type {unknown} */ (cleanedTypes)), typedData.message, ); @@ -741,11 +752,14 @@ class SwapIFrameApi extends BitcoinRequestParserMixin(RequestParser) { // eslint }; } - if (parsedRequest.redeem.type === 'EUR' && storedRequest.redeem.type === 'EUR') { + if ((parsedRequest.redeem.type === 'EUR' && storedRequest.redeem.type === 'EUR') + || (parsedRequest.redeem.type === 'CRC' && storedRequest.redeem.type === 'CRC')) { await loadNimiq(); + const fiatName = parsedRequest.redeem.type.toLocaleLowerCase(); // Create and sign a JWS of the settlement instructions - const privateKey = new Nimiq.PrivateKey(Nimiq.BufferUtils.fromHex(privateKeys.eur)); + // @ts-ignore The privateKeyName is checked in the if clause + const privateKey = new Nimiq.PrivateKey(Nimiq.BufferUtils.fromHex(privateKeys[fiatName])); const key = new Key(privateKey); /** @type {KeyguardRequest.SettlementInstruction} */ @@ -754,12 +768,13 @@ class SwapIFrameApi extends BitcoinRequestParserMixin(RequestParser) { // eslint contractId: parsedRequest.redeem.htlcId, }; - if (settlement.type === 'sepa') { + if (fiatName === 'eur' && settlement.type === 'sepa') { // Remove spaces from IBAN settlement.recipient.iban = settlement.recipient.iban.replace(/\s/g, ''); } - result.eur = OasisSettlementInstructionUtils.signSettlementInstruction(key, 'm', settlement); + // @ts-ignore The privateKeyName is checked in the if clause + result[fiatName] = OasisSettlementInstructionUtils.signSettlementInstruction(key, 'm', settlement); } return result; diff --git a/types/Keyguard.d.ts b/types/Keyguard.d.ts index e98ca1ac8..d1ec0d96b 100644 --- a/types/Keyguard.d.ts +++ b/types/Keyguard.d.ts @@ -184,6 +184,11 @@ type EurHtlcContents = { timeoutTimestamp: number, }; +type CrcHtlcContents = { + hash: string, + timeoutTimestamp: number +} + type Transform = Omit & E; type KeyId2KeyInfo = Transform @@ -219,6 +224,11 @@ type ConstructSwap = Transform< bankLabel?: string, // bankLogoUrl?: string, // bankColor?: string, + } | { + type: 'CRC', + amount: number, + fee: number, + senderLabel?: string, }, redeem: { type: 'NIM', @@ -249,6 +259,15 @@ type ConstructSwap = Transform< bankLabel?: string, // bankLogoUrl?: string, // bankColor?: string, + } | { + type: 'CRC', + keyPath: string, + // A SettlementInstruction contains a `type`, so cannot be in the + // root of the object (it conflicts with the 'CRC' type). + settlement: Omit | Omit, + amount: number, + fee: number, + recipientLabel?: string, }, }> @@ -349,7 +368,11 @@ type Parsed = type: 'EUR', htlcDetails: EurHtlcContents, htlcId: string, - }, + } | { + type: 'CRC', + htlcDetails: CrcHtlcContents, + htlcId: string, + }; redeem: { type: 'NIM', htlcDetails: NimHtlcContents, @@ -373,6 +396,10 @@ type Parsed = type: 'EUR', htlcDetails: EurHtlcContents, htlcId: string, + } | { + type: 'CRC', + htlcDetails: CrcHtlcContents, + htlcId: string, }, } > :