diff --git a/dev/config/base-config.yaml b/dev/config/base-config.yaml index f1961e86a..7fea1e6b5 100644 --- a/dev/config/base-config.yaml +++ b/dev/config/base-config.yaml @@ -3,6 +3,9 @@ admin_accounts: - role: "bankowner" phone: "+16505554334" +cashu: + mintUrl: "https://forge.flashapp.me" + ibex: url: "https://api-sandbox.poweredbyibex.io" email: "" diff --git a/package.json b/package.json index b48d43be7..74333f197 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "@google-cloud/storage": "^7.1.0", "@grpc/grpc-js": "^1.9.3", "@grpc/proto-loader": "^0.7.9", + "@lnflash/cashu-client": "lnflash/cashu-client#v0.1.0", "@opentelemetry/api": "^1.6.0", "@opentelemetry/core": "^1.17.0", "@opentelemetry/exporter-trace-otlp-http": "^0.43.0", diff --git a/src/app/cashu/index.ts b/src/app/cashu/index.ts new file mode 100644 index 000000000..e4f77aa05 --- /dev/null +++ b/src/app/cashu/index.ts @@ -0,0 +1 @@ +export * from "./provision-card" diff --git a/src/app/cashu/provision-card.ts b/src/app/cashu/provision-card.ts new file mode 100644 index 000000000..bbb2397d3 --- /dev/null +++ b/src/app/cashu/provision-card.ts @@ -0,0 +1,255 @@ +import { + createBlindedMessage, + unblindSignature, + splitIntoDenominations, + CashuMintError, + CashuInvalidCardPubkeyError, + CashuBlindingError, + CashuMintQuoteNotPaidError, + CashuInsufficientSlotsError, +} from "@domain/cashu" + +import { + CashuInsufficientSlotsError as PkgCashuInsufficientSlotsError, +} from "@lnflash/cashu-client" + +import type { + CashuProof, + CashuBlindingData, + CashuBlindedMessage, + CashuBlindSignature, +} from "@domain/cashu" + +type CashuCardProvisionResult = { + proofs: CashuProof[] + cardPubkey: string + totalAmount: number // cents +} + +import * as secp from "tiny-secp256k1" + +import { + requestMintQuote, + getMintKeysets, + getMintKeyset, + mintProofs, +} from "@services/cashu" + +import { AccountsRepository, WalletsRepository } from "@services/mongoose" +import { AccountValidator } from "@domain/accounts" +import { checkedToWalletId } from "@domain/wallets" +import { baseLogger } from "@services/logger" +import { payInvoiceByWalletId } from "@app/payments" + +const logger = baseLogger.child({ module: "cashu-provision-card" }) + +const CASHU_UNIT = "usd" + +/** + * ENG-174/ENG-175: Cashu card provisioning and top-up + * + * Handles both first-time provisioning (card is blank, all 32 slots free) + * and subsequent top-ups (card has existing proofs, availableSlots < 32). + * + * Flow: + * 1. Validate wallet belongs to account and has sufficient balance + * 2. Fetch active USD keyset from mint + * 3. Request mint quote (get Lightning invoice) + * 4. Pay invoice from user's USD wallet + * 5. Split amount into denominations, respecting availableSlots if provided + * 6. Build P2PK-locked blind messages for each denomination + * 7. Submit to mint → receive blind signatures (with retry on quote-not-yet-PAID) + * 8. Unblind signatures → proofs + * 9. Return proofs to caller (POS/mobile writes to card via LOAD_PROOF APDUs) + */ +export const provisionCashuCard = async ({ + walletId: uncheckedWalletId, + accountId, + amountCents, + cardPubkey, + availableSlots, +}: { + walletId: string + accountId: string + amountCents: number + cardPubkey: string + availableSlots?: number +}): Promise => { + // --- 1. Validate inputs --- + const walletId = checkedToWalletId(uncheckedWalletId) + if (walletId instanceof Error) return walletId + + if (amountCents <= 0 || !Number.isInteger(amountCents)) { + return new CashuMintError("amountCents must be a positive integer") + } + + // Validate card pubkey: must be 33-byte compressed secp256k1 point + let cardPubkeyBytes: Uint8Array + try { + cardPubkeyBytes = Buffer.from(cardPubkey, "hex") + if (cardPubkeyBytes.length !== 33 || !secp.isPoint(cardPubkeyBytes)) { + return new CashuInvalidCardPubkeyError( + "cardPubkey must be a 33-byte compressed secp256k1 point (66 hex chars)", + ) + } + } catch { + return new CashuInvalidCardPubkeyError("cardPubkey is not valid hex") + } + + // --- 2. Validate account + wallet --- + const account = await AccountsRepository().findById(accountId as AccountId) + if (account instanceof Error) return account + + const accountValidator = AccountValidator(account).isActive() + if (accountValidator instanceof Error) return accountValidator + + const wallet = await WalletsRepository().findById(walletId) + if (wallet instanceof Error) return wallet + + if (wallet.accountId !== accountId) { + return new CashuMintError("Wallet does not belong to the current account") + } + + if (wallet.currency !== "USD") { + return new CashuMintError("Card provisioning requires a USD wallet") + } + + // --- 3. Fetch active USD keyset from mint --- + const keysets = await getMintKeysets() + if (keysets instanceof Error) return keysets + + const usdKeyset = keysets.find((ks) => ks.unit === CASHU_UNIT && ks.active) + if (!usdKeyset) { + return new CashuMintError("No active USD keyset found on mint") + } + + const keysetDetail = await getMintKeyset(usdKeyset.id) + if (keysetDetail instanceof Error) return keysetDetail + + // keys is { "1": pubkey_hex, "2": pubkey_hex, ... } + const mintKeys: Record = keysetDetail.keys + + // --- 4. Request mint quote --- + const quote = await requestMintQuote(amountCents) + if (quote instanceof Error) return quote + + logger.info({ quoteId: quote.quoteId, amountCents }, "cashu: mint quote received") + + // --- 5. Pay the mint invoice from user's wallet --- + const payResult = await payInvoiceByWalletId({ + uncheckedPaymentRequest: quote.paymentRequest, + memo: `Cashu card provisioning — ${amountCents} cents`, + senderWalletId: walletId as WalletId, + senderAccount: account, + }) + if (payResult instanceof Error) return payResult + + logger.info({ quoteId: quote.quoteId }, "cashu: mint invoice paid") + + // --- 6. Build P2PK blind messages --- + let denominations: number[] + try { + denominations = splitIntoDenominations(amountCents, availableSlots) + } catch (err) { + if (err instanceof PkgCashuInsufficientSlotsError) { + return new CashuInsufficientSlotsError(err.message) + } + return new CashuMintError(`Denomination split failed: ${(err as Error).message}`) + } + + const blindingDataList: (CashuBlindingData & { keysetId: string })[] = [] + const blindedMessages: CashuBlindedMessage[] = [] + + for (const amount of denominations) { + if (!mintKeys[String(amount)]) { + return new CashuMintError( + `Mint keyset has no key for denomination ${amount} cents`, + ) + } + try { + const bd = createBlindedMessage(usdKeyset.id, amount, cardPubkey) + blindingDataList.push({ ...bd, keysetId: usdKeyset.id }) + blindedMessages.push({ id: usdKeyset.id, amount, B_: bd.B_ }) + } catch (err) { + logger.error({ err, amount }, "cashu: blinding failed") + return new CashuBlindingError(`Blinding failed for amount ${amount}: ${(err as Error).message}`) + } + } + + // --- 7. Submit to mint, receive blind signatures (with retry on quote-not-yet-PAID) --- + // + // We don't pre-check quote state. The mint is the authoritative check: + // - If paid → returns signatures immediately + // - If not yet processed → returns HTTP 400 "quote not paid" + // + // There is a small window between our Lightning payment settling and the mint's + // internal state updating (webhook/polling from its LN node). We retry with + // exponential backoff on that specific error. This is the pattern used by + // cashu-ts (reference wallet) — attempt mint directly, retry on "quote not paid". + const RETRY_DELAYS_MS = [500, 1000, 2000, 4000] // max ~7.5s total + const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + + let blindSigs: CashuBlindSignature[] | ApplicationError | undefined + for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) { + blindSigs = await mintProofs(quote.quoteId, blindedMessages) + if (!(blindSigs instanceof Error)) break + + const isNotPaid = + blindSigs instanceof CashuMintError && + blindSigs.message.toLowerCase().includes("quote not paid") + + if (!isNotPaid || attempt === RETRY_DELAYS_MS.length) { + return blindSigs + } + + logger.warn( + { quoteId: quote.quoteId, attempt: attempt + 1, delayMs: RETRY_DELAYS_MS[attempt] }, + "cashu: quote not yet PAID on mint, retrying", + ) + await sleep(RETRY_DELAYS_MS[attempt]) + } + + if (!blindSigs || blindSigs instanceof Error) { + return new CashuMintQuoteNotPaidError("Mint did not confirm payment after retries") + } + + // --- 8. Unblind signatures → final proofs --- + const proofs: CashuProof[] = [] + const confirmedSigs = blindSigs as CashuBlindSignature[] + + for (let i = 0; i < confirmedSigs.length; i++) { + const sig = confirmedSigs[i] + const bd = blindingDataList[i] + const mintPubkey = mintKeys[String(sig.amount)] + + if (!mintPubkey) { + return new CashuBlindingError(`No mint pubkey for amount ${sig.amount}`) + } + + let C: string + try { + C = unblindSignature(sig.C_, bd.r, mintPubkey) + } catch (err) { + logger.error({ err, amount: sig.amount }, "cashu: unblinding failed") + return new CashuBlindingError(`Unblinding failed: ${(err as Error).message}`) + } + + proofs.push({ + id: sig.id, + amount: sig.amount, + secret: bd.secretStr, // full NUT-10 P2PK JSON string + C, + }) + } + + logger.info( + { cardPubkey: cardPubkey.slice(0, 10) + "…", proofCount: proofs.length, amountCents }, + "cashu: card provisioned successfully", + ) + + return { + proofs, + cardPubkey, + totalAmount: amountCents, + } +} diff --git a/src/app/index.ts b/src/app/index.ts index b006e8446..149abe47b 100644 --- a/src/app/index.ts +++ b/src/app/index.ts @@ -14,8 +14,10 @@ import * as WalletsMod from "./wallets" import * as PaymentsMod from "./payments" import * as MerchantsMod from "./merchants" import * as SwapMod from "./swap" +import * as CashuMod from "./cashu" const allFunctions = { + Cashu: { ...CashuMod }, Accounts: { ...AccountsMod }, Authentication: { ...AuthenticationMod }, Admin: { ...AdminMod }, @@ -46,6 +48,7 @@ for (subModule in allFunctions) { } export const { + Cashu, Accounts, Authentication, Admin, diff --git a/src/config/schema.types.d.ts b/src/config/schema.types.d.ts index 4ae4daa90..b41a90c57 100644 --- a/src/config/schema.types.d.ts +++ b/src/config/schema.types.d.ts @@ -29,6 +29,10 @@ type IbexConfig = { webhook: WebhookServer } +type CashuConfig = { + mintUrl: string +} + type CashoutEmail = { to: string from: string @@ -172,6 +176,7 @@ type YamlSchema = { smsAuthUnsupportedCountries: string[] whatsAppAuthUnsupportedCountries: string[] ibex: IbexConfig, + cashu?: CashuConfig, exchangeRates: StaticRates cashout: { enabled: boolean diff --git a/src/config/yaml.ts b/src/config/yaml.ts index 98b9c5ab3..2d0ecfd80 100644 --- a/src/config/yaml.ts +++ b/src/config/yaml.ts @@ -388,4 +388,8 @@ export const SendGridConfig = yamlConfig.sendgrid as SendGridConfig export const IbexConfig = yamlConfig.ibex as IbexConfig -export const FrappeConfig = yamlConfig.frappe as FrappeConfig \ No newline at end of file +export const getCashuConfig = (): CashuConfig => ({ + mintUrl: yamlConfig.cashu?.mintUrl ?? "https://forge.flashapp.me", +}) + +export const FrappeConfig = yamlConfig.frappe as FrappeConfig diff --git a/src/domain/cashu/errors.ts b/src/domain/cashu/errors.ts new file mode 100644 index 000000000..f177a3466 --- /dev/null +++ b/src/domain/cashu/errors.ts @@ -0,0 +1,36 @@ +/** + * Flash-layer Cashu error wrappers. + * + * These extend DomainError (for Flash ErrorLevel metadata/logging) while + * delegating message construction to the same names from @lnflash/cashu-client. + * Callers use instanceof checks against these types. + */ +import { DomainError, ErrorLevel } from "@domain/shared" + +export class CashuMintError extends DomainError { + level = ErrorLevel.Critical +} + +export class CashuMintQuoteNotPaidError extends DomainError { + level = ErrorLevel.Warn +} + +export class CashuInvalidCardPubkeyError extends DomainError { + level = ErrorLevel.Warn +} + +export class CashuBlindingError extends DomainError { + level = ErrorLevel.Critical +} + +export class CashuInvalidProofError extends DomainError { + level = ErrorLevel.Warn +} + +export class CashuInsufficientSlotsError extends DomainError { + level = ErrorLevel.Warn +} + +export class CashuInsufficientBalanceError extends DomainError { + level = ErrorLevel.Warn +} diff --git a/src/domain/cashu/index.ts b/src/domain/cashu/index.ts new file mode 100644 index 000000000..6a6e516c3 --- /dev/null +++ b/src/domain/cashu/index.ts @@ -0,0 +1,25 @@ +/** + * Cashu domain layer — re-exports from @lnflash/cashu-client plus Flash error wrappers. + * + * Crypto primitives and types live in the standalone package. + * Flash-specific DomainError subclasses live in ./errors. + */ +export { + hashToCurve, + splitIntoDenominations, + buildP2PKSecret, + createBlindedMessage, + unblindSignature, +} from "@lnflash/cashu-client" + +export type { + CashuProof, + CashuMintQuote, + CashuBlindedMessage, + CashuBlindSignature, + CashuBlindingData, + CashuKeyset, + CashuKeysetDetail, +} from "@lnflash/cashu-client" + +export * from "./errors" diff --git a/src/graphql/public/mutations.ts b/src/graphql/public/mutations.ts index 6f9262d28..f71fdc554 100644 --- a/src/graphql/public/mutations.ts +++ b/src/graphql/public/mutations.ts @@ -59,6 +59,7 @@ import AccountDisableNotificationChannelMutation from "./root/mutation/account-d import UserUpdateNpubMutation from "./root/mutation/user-update-npub" import RequestCashoutMutation from "./root/mutation/offers/request-cash-out" import InitiateCashoutMutation from "./root/mutation/offers/initiate-cash-out" +import CashuCardProvisionMutation from "./root/mutation/cashu-card-provision" import IdDocumentUploadUrlGenerateMutation from "./root/mutation/id-document-upload-url-generate" // TODO: // const fields: { [key: string]: GraphQLFieldConfig } @@ -142,6 +143,7 @@ export const mutationFields = { requestCashout: RequestCashoutMutation, initiateCashout: InitiateCashoutMutation, + cashuCardProvision: CashuCardProvisionMutation, }, }, } diff --git a/src/graphql/public/root/mutation/cashu-card-provision.ts b/src/graphql/public/root/mutation/cashu-card-provision.ts new file mode 100644 index 000000000..8ffaf3ec3 --- /dev/null +++ b/src/graphql/public/root/mutation/cashu-card-provision.ts @@ -0,0 +1,104 @@ +import dedent from "dedent" + +import { GT } from "@graphql/index" +import WalletId from "@graphql/shared/types/scalar/wallet-id" +import CashuCardProvisionPayload from "@graphql/public/types/payload/cashu-card-provision" +import { mapAndParseErrorForGqlResponse } from "@graphql/error-map" +import { Cashu } from "@app" + +const CashuCardProvisionInput = GT.Input({ + name: "CashuCardProvisionInput", + fields: () => ({ + walletId: { + type: GT.NonNull(WalletId), + description: "USD wallet ID to fund the card from.", + }, + amountCents: { + type: GT.NonNull(GT.Int), + description: "Total amount to load onto the card, in USD cents.", + }, + cardPubkey: { + type: GT.NonNull(GT.String), + description: + "The card's compressed secp256k1 public key (33 bytes, 66 hex chars). " + + "Obtained from the NFC card via GET_PUBKEY APDU (INS: 0x10).", + }, + availableSlots: { + type: GT.Int, + description: + "Number of free proof slots on the card. " + + "Required for top-ups where some slots are already in use. " + + "Omit (or pass 32) for first-time provisioning. " + + "Returns an error if the amount requires more denominations than available slots.", + }, + }), +}) + +const CashuCardProvisionMutation = GT.Field({ + extensions: { + complexity: 120, + }, + type: GT.NonNull(CashuCardProvisionPayload), + description: dedent` + Provisions a Cashu NFC card with ecash proofs (NUT-XX Profile B). + + Flow: + 1. Tap card to read its public key (GET_PUBKEY APDU) + 2. Call this mutation with the card pubkey, wallet ID, and amount + 3. The backend pays the Cashu mint from your USD wallet balance + 4. Returns signed Cashu proofs locked to the card's key + 5. Write each proof to the card via LOAD_PROOF APDUs + + The card can then be used for offline payments. Merchant redeems proofs + online by presenting them to the mint with the card's Schnorr signature. + `, + args: { + input: { type: GT.NonNull(CashuCardProvisionInput) }, + }, + resolve: async (_, args, { domainAccount }: { domainAccount: Account }) => { + const { walletId, amountCents, cardPubkey, availableSlots } = args.input + + for (const input of [walletId, amountCents, cardPubkey]) { + if (input instanceof Error) { + return { errors: [{ message: input.message }] } + } + } + + if (typeof amountCents !== "number" || amountCents <= 0) { + return { errors: [{ message: "amountCents must be a positive integer" }] } + } + + if (typeof cardPubkey !== "string" || cardPubkey.length !== 66) { + return { + errors: [{ message: "cardPubkey must be 66 hex characters (33 bytes compressed)" }], + } + } + + if (availableSlots !== undefined && availableSlots !== null) { + if (!Number.isInteger(availableSlots) || availableSlots < 1 || availableSlots > 32) { + return { errors: [{ message: "availableSlots must be an integer between 1 and 32" }] } + } + } + + const result = await Cashu.provisionCashuCard({ + walletId, + accountId: domainAccount.id, + amountCents, + cardPubkey, + availableSlots: availableSlots ?? undefined, + }) + + if (result instanceof Error) { + return { errors: [mapAndParseErrorForGqlResponse(result)] } + } + + return { + errors: [], + proofs: result.proofs, + cardPubkey: result.cardPubkey, + totalAmountCents: result.totalAmount, + } + }, +}) + +export default CashuCardProvisionMutation diff --git a/src/graphql/public/types/object/cashu-proof.ts b/src/graphql/public/types/object/cashu-proof.ts new file mode 100644 index 000000000..07f118751 --- /dev/null +++ b/src/graphql/public/types/object/cashu-proof.ts @@ -0,0 +1,29 @@ +import { GT } from "@graphql/index" + +const CashuProof = GT.Object({ + name: "CashuProof", + description: + "A Cashu proof locked to a card's public key (NUT-XX Profile B). " + + "Write to the NFC card via LOAD_PROOF APDU after provisioning.", + fields: () => ({ + id: { + type: GT.NonNull(GT.String), + description: "Keyset ID (hex string, e.g. '0059534ce0bfa19a').", + }, + amount: { + type: GT.NonNull(GT.Int), + description: "Denomination in USD cents.", + }, + secret: { + type: GT.NonNull(GT.String), + description: + "NUT-10 P2PK secret JSON string. Contains the nonce and the card's public key.", + }, + C: { + type: GT.NonNull(GT.String), + description: "Mint blind signature (compressed secp256k1 point, hex).", + }, + }), +}) + +export default CashuProof diff --git a/src/graphql/public/types/payload/cashu-card-provision.ts b/src/graphql/public/types/payload/cashu-card-provision.ts new file mode 100644 index 000000000..9ca63b844 --- /dev/null +++ b/src/graphql/public/types/payload/cashu-card-provision.ts @@ -0,0 +1,28 @@ +import { GT } from "@graphql/index" + +import IError from "@graphql/shared/types/abstract/error" +import CashuProof from "@graphql/public/types/object/cashu-proof" + +const CashuCardProvisionPayload = GT.Object({ + name: "CashuCardProvisionPayload", + fields: () => ({ + errors: { + type: GT.NonNullList(IError), + }, + proofs: { + type: GT.List(GT.NonNull(CashuProof)), + description: + "Signed Cashu proofs ready to be written to the NFC card via LOAD_PROOF APDUs.", + }, + cardPubkey: { + type: GT.String, + description: "The card's public key (hex), echoed back for verification.", + }, + totalAmountCents: { + type: GT.Int, + description: "Total amount loaded onto the card, in USD cents.", + }, + }), +}) + +export default CashuCardProvisionPayload diff --git a/src/services/cashu/index.ts b/src/services/cashu/index.ts new file mode 100644 index 000000000..89fef7096 --- /dev/null +++ b/src/services/cashu/index.ts @@ -0,0 +1,60 @@ +/** + * Flash Cashu service layer. + * + * Thin wrappers around @lnflash/cashu-client mint functions that: + * 1. Inject the configured mint URL from Flash's YAML config + * 2. Map package-level CashuError instances to Flash DomainError subclasses + * (preserving ErrorLevel metadata for Flash's logging/error handling) + */ +import { + requestMintQuote as _requestMintQuote, + getMintQuoteState as _getMintQuoteState, + getMintKeysets as _getMintKeysets, + getMintKeyset as _getMintKeyset, + mintProofs as _mintProofs, + CashuError, +} from "@lnflash/cashu-client" + +import type { CashuMintQuote, CashuBlindedMessage, CashuBlindSignature, CashuKeyset, CashuKeysetDetail } from "@lnflash/cashu-client" + +import { CashuMintError } from "@domain/cashu" +import { getCashuConfig } from "@config" + +const mintUrl = () => getCashuConfig().mintUrl + +const wrapError = (err: CashuError): CashuMintError => + new CashuMintError(err.message) + +export const requestMintQuote = async ( + amountCents: number, +): Promise => { + const result = await _requestMintQuote(mintUrl(), amountCents, "usd") + return result instanceof CashuError ? wrapError(result) : result +} + +export const getMintQuoteState = async ( + quoteId: string, +): Promise => { + const result = await _getMintQuoteState(mintUrl(), quoteId) + return result instanceof CashuError ? wrapError(result) : result +} + +export const getMintKeysets = async (): Promise => { + const result = await _getMintKeysets(mintUrl()) + return result instanceof CashuError ? wrapError(result) : result +} + +export const getMintKeyset = async ( + keysetId: string, +): Promise => { + const result = await _getMintKeyset(mintUrl(), keysetId) + return result instanceof CashuError ? wrapError(result) : result +} + +export const mintProofs = async ( + quoteId: string, + blindedMessages: CashuBlindedMessage[], +): Promise => { + const result = await _mintProofs(mintUrl(), quoteId, blindedMessages) + return result instanceof CashuError ? wrapError(result) : result +} diff --git a/yarn.lock b/yarn.lock index 1b016c30d..a6ff822cc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2503,6 +2503,13 @@ dependencies: lodash "^4.17.21" +"@lnflash/cashu-client@lnflash/cashu-client#v0.1.0": + version "0.1.0" + resolved "https://codeload.github.com/lnflash/cashu-client/tar.gz/a54d8d57d8e1c576b0bb9ea083ab84cc7c524d89" + dependencies: + axios "^1.6.0" + tiny-secp256k1 "^2.2.3" + "@mapbox/node-pre-gyp@^1.0.5": version "1.0.11" resolved "https://registry.yarnpkg.com/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz#417db42b7f5323d79e93b34a6d7a2a12c0df43fa" @@ -13227,7 +13234,16 @@ string-template@~0.2.1: resolved "https://registry.yarnpkg.com/string-template/-/string-template-0.2.1.tgz#42932e598a352d01fc22ec3367d9d84eec6c9add" integrity sha512-Yptehjogou2xm4UJbxJ4CxgZx12HBfeystp0y3x7s4Dj32ltVVG1Gg8YhKjHZkHicuKpZX/ffilA8505VbUbpw== -"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -13305,7 +13321,7 @@ stringify-object@^3.2.1: is-obj "^1.0.1" is-regexp "^1.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -13319,6 +13335,13 @@ strip-ansi@^3.0.0: dependencies: ansi-regex "^2.0.0" +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + strip-ansi@^7.0.1: version "7.1.2" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.2.tgz#132875abde678c7ea8d691533f2e7e22bb744dba" @@ -14421,7 +14444,16 @@ wordwrap@^1.0.0: resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==