Skip to content

Commit

Permalink
[core] Only allow preferred_currency_value_approx to override value f…
Browse files Browse the repository at this point in the history
…or the corresponding unit (#13403)

GitOrigin-RevId: 60b15667c084c85734b889fc21e4e38a622897d1
  • Loading branch information
coreymartin authored and Lightspark Eng committed Nov 14, 2024
1 parent deffd4d commit 1856df2
Show file tree
Hide file tree
Showing 2 changed files with 220 additions and 30 deletions.
141 changes: 112 additions & 29 deletions packages/core/src/utils/currency.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ export function convertCurrencyAmountValue(
units to provide value estimates where needed where a backend value is not available, eg
previewing the approximate value of an amount to send. */
unitsPerBtc = 1,
): number {
) {
if (
fromUnit === CurrencyUnit.FUTURE_VALUE ||
toUnit === CurrencyUnit.FUTURE_VALUE
Expand Down Expand Up @@ -220,41 +220,69 @@ export type CurrencyMap = {
type: "CurrencyMap";
};

/* GQL CurrencyAmountInputs have this shape as well as client side CurrencyAmount objects.
* Technically value is always a number for GQL inputs. This is enforced by mutation input
* types. For client side utils we can have slightly more forgiving input and coerce with
* asNumber. */
export type CurrencyAmountInputObj = {
value: number | string | null;
unit: CurrencyUnitType;
};

/* Persisted CurrencyAmount objects may have this shape if queried from GQL in this format
but the fields are deprecated and original_unit and original_value should be used instead: */
export type DeprecatedCurrencyAmountObj = {
/* Technically the generated graphql schema has value as `any` but it's always a number.
* We are intentionally widening the type here to allow for more forgiving input: */
value?: number | string | null;
/* Technically the generated graphql schema has value as `any` but it's always a number: */
value?: number;
/* assume satoshi if not provided */
unit?: CurrencyUnitType;
__typename?: "CurrencyAmount" | undefined;
__typename?: "CurrencyAmount";
};

export type CurrencyAmountObj = {
/* Technically the generated graphql schema has value as `any` but it's always a number.
* We are intentionally widening the type here to allow for more forgiving input: */
original_value?: number | string | null;
/* Technically the generated graphql schema has value as `any` but it's always a number: */
original_value?: number;
/* assume satoshi if not provided */
original_unit?: CurrencyUnitType;
__typename?: "CurrencyAmount" | undefined;
__typename?: "CurrencyAmount";
};

export type CurrencyAmountPreferenceObj = {
/* Technically the generated graphql schema has value as `any` but it's always a number.
* We are intentionally widening the type here to allow for more forgiving input: */
preferred_currency_unit?: CurrencyUnitType;
/* assume satoshi if not provided */
preferred_currency_value_rounded?: number | string | null;
__typename?: "CurrencyAmount" | undefined;
/* unit and value, along with original unit and value are all required for
* CurrencyAmountPreferenceObj - the preferred value is used for the corresponding unit
* but the original unit/value are also needed to ensure accurate conversion to other units */
original_value: number;
original_unit: CurrencyUnitType;
preferred_currency_unit: CurrencyUnitType;
/* Technically the generated graphql schema has value as `any` but it's always a number: */
preferred_currency_value_approx: number;
__typename?: "CurrencyAmount";
};

export type CurrencyAmountArg =
| CurrencyAmountInputObj
| DeprecatedCurrencyAmountObj
| CurrencyAmountObj
| CurrencyAmountPreferenceObj
| SDKCurrencyAmountType
| undefined
| null;

export function isCurrencyAmountInputObj(
arg: unknown,
): arg is CurrencyAmountInputObj {
return (
typeof arg === "object" &&
arg !== null &&
"value" in arg &&
(typeof arg.value === "number" ||
typeof arg.value === "string" ||
arg.value === null) &&
"unit" in arg &&
typeof arg.unit === "string"
);
}

export function isDeprecatedCurrencyAmountObj(
arg: unknown,
): arg is DeprecatedCurrencyAmountObj {
Expand All @@ -278,8 +306,14 @@ export function isCurrencyAmountPreferenceObj(
return (
typeof arg === "object" &&
arg !== null &&
"original_unit" in arg &&
typeof arg.original_unit === "string" &&
"original_value" in arg &&
typeof arg.original_value === "number" &&
"preferred_currency_unit" in arg &&
"preferred_currency_value_rounded" in arg
typeof arg.preferred_currency_unit === "string" &&
"preferred_currency_value_approx" in arg &&
typeof arg.preferred_currency_value_approx === "number"
);
}

Expand Down Expand Up @@ -311,13 +345,13 @@ function getCurrencyAmount(currencyAmountArg: CurrencyAmountArg) {
if (isSDKCurrencyAmount(currencyAmountArg)) {
value = currencyAmountArg.originalValue;
unit = currencyAmountArg.originalUnit;
} else if (isCurrencyAmountPreferenceObj(currencyAmountArg)) {
value = asNumber(currencyAmountArg.preferred_currency_value_rounded);
unit = currencyAmountArg.preferred_currency_unit;
} else if (isCurrencyAmountObj(currencyAmountArg)) {
value = asNumber(currencyAmountArg.original_value);
unit = currencyAmountArg.original_unit;
} else if (isDeprecatedCurrencyAmountObj(currencyAmountArg)) {
} else if (
isCurrencyAmountInputObj(currencyAmountArg) ||
isDeprecatedCurrencyAmountObj(currencyAmountArg)
) {
value = asNumber(currencyAmountArg.value);
unit = currencyAmountArg.unit;
}
Expand All @@ -328,21 +362,70 @@ function getCurrencyAmount(currencyAmountArg: CurrencyAmountArg) {
};
}

function convertCurrencyAmountValues(
fromUnit: CurrencyUnitType,
amount: number,
unitsPerBtc = 1,
conversionOverride?: { unit: CurrencyUnitType; convertedValue: number },
) {
const convert = convertCurrencyAmountValue;
const namesToUnits = {
sats: CurrencyUnit.SATOSHI,
btc: CurrencyUnit.BITCOIN,
msats: CurrencyUnit.MILLISATOSHI,
usd: CurrencyUnit.USD,
mxn: CurrencyUnit.MXN,
mibtc: CurrencyUnit.MICROBITCOIN,
mlbtc: CurrencyUnit.MILLIBITCOIN,
nbtc: CurrencyUnit.NANOBITCOIN,
};
return Object.entries(namesToUnits).reduce(
(acc, [name, unit]) => {
if (conversionOverride && unit === conversionOverride.unit) {
acc[name as keyof typeof namesToUnits] =
conversionOverride.convertedValue;
} else {
acc[name as keyof typeof namesToUnits] = convert(
fromUnit,
unit,
amount,
unitsPerBtc,
);
}
return acc;
},
{} as Record<keyof typeof namesToUnits, number>,
);
}

function getPreferredConversionOverride(currencyAmountArg: CurrencyAmountArg) {
if (isCurrencyAmountPreferenceObj(currencyAmountArg)) {
return {
unit: currencyAmountArg.preferred_currency_unit,
convertedValue: currencyAmountArg.preferred_currency_value_approx,
};
} else if (isSDKCurrencyAmount(currencyAmountArg)) {
return {
unit: currencyAmountArg.preferredCurrencyUnit,
convertedValue: currencyAmountArg.preferredCurrencyValueApprox,
};
}
return undefined;
}

export function mapCurrencyAmount(
currencyAmountArg: CurrencyAmountArg,
unitsPerBtc = 1,
): CurrencyMap {
const { value, unit } = getCurrencyAmount(currencyAmountArg);

const convert = convertCurrencyAmountValue;
const sats = convert(unit, CurrencyUnit.SATOSHI, value, unitsPerBtc);
const btc = convert(unit, CurrencyUnit.BITCOIN, value, unitsPerBtc);
const msats = convert(unit, CurrencyUnit.MILLISATOSHI, value, unitsPerBtc);
const usd = convert(unit, CurrencyUnit.USD, value, unitsPerBtc);
const mxn = convert(unit, CurrencyUnit.MXN, value, unitsPerBtc);
const mibtc = convert(unit, CurrencyUnit.MICROBITCOIN, value, unitsPerBtc);
const mlbtc = convert(unit, CurrencyUnit.MILLIBITCOIN, value, unitsPerBtc);
const nbtc = convert(unit, CurrencyUnit.NANOBITCOIN, value, unitsPerBtc);
/* Prefer approximation from backend for corresponding unit if specified on currencyAmountArg.
* This will always be at most for one single unit type since there's only one
* preferred_currency_unit on CurrencyAmount types: */
const conversionOverride = getPreferredConversionOverride(currencyAmountArg);

const { sats, msats, btc, usd, mxn, mibtc, mlbtc, nbtc } =
convertCurrencyAmountValues(unit, value, unitsPerBtc, conversionOverride);

const mapWithCurrencyUnits = {
[CurrencyUnit.BITCOIN]: btc,
Expand Down
109 changes: 108 additions & 1 deletion packages/core/src/utils/tests/currency.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ describe("convertCurrencyAmountValue", () => {
});

describe("mapCurrencyAmount", () => {
it("should return the expected value for a CurrencyAmountObj", () => {
it("should return the expected value for a CurrencyAmountInputObj with number value", () => {
const currencyMap = mapCurrencyAmount(
{
value: 100_000_000,
Expand Down Expand Up @@ -170,6 +170,113 @@ describe("mapCurrencyAmount", () => {
expect(smallerCurrencyMap.isGreaterThan(currencyMap)).toBe(false);
expect(smallerCurrencyMap.isLessThan(currencyMap)).toBe(true);
});

it("should return the expected value for a CurrencyAmountInputObj with string value", () => {
const currencyMap = mapCurrencyAmount(
{
value: "100000000",
unit: CurrencyUnit.SATOSHI,
},
25_000_00,
);
expect(currencyMap.btc).toBe(1);
expect(currencyMap.USD).toBe(25_000_00);
expect(currencyMap.sats).toBe(100_000_000);
expect(currencyMap.msats).toBe(100_000_000_000);
expect(currencyMap.formatted.btc).toBe("1");
expect(currencyMap.formatted.USD).toBe("$25,000.00");
expect(currencyMap.formatted.sats).toBe("100,000,000");
expect(currencyMap.formatted.msats).toBe("100,000,000,000");
});

it("should return the expected value for a CurrencyAmountInputObj with null value", () => {
const currencyMap = mapCurrencyAmount(
{
value: null,
unit: CurrencyUnit.SATOSHI,
},
25_000_00,
);
expect(currencyMap.btc).toBe(0);
expect(currencyMap.USD).toBe(0);
expect(currencyMap.sats).toBe(0);
expect(currencyMap.msats).toBe(0);
expect(currencyMap.formatted.btc).toBe("0");
expect(currencyMap.formatted.USD).toBe("$0.00");
expect(currencyMap.formatted.sats).toBe("0");
expect(currencyMap.formatted.msats).toBe("0");
});

it("should return the expected value for a CurrencyAmountObj", () => {
const currencyMap = mapCurrencyAmount(
{
original_value: 147,
original_unit: CurrencyUnit.SATOSHI,
},
25_000_00,
);
expect(currencyMap.btc).toBe(0.00000147);
expect(currencyMap.USD).toBe(4); // 0.03675 should round to 4 cents
expect(currencyMap.sats).toBe(147);
expect(currencyMap.msats).toBe(147_000);
expect(currencyMap.formatted.btc).toBe("0");
expect(currencyMap.formatted.USD).toBe("$0.04");
expect(currencyMap.formatted.sats).toBe("147");
expect(currencyMap.formatted.msats).toBe("147,000");
});

it("should have a type error when extra fields are provided as CurrencyAmountArg", () => {
mapCurrencyAmount(
{
original_value: 147,
original_unit: CurrencyUnit.SATOSHI,
/* @ts-expect-error `value` cannot be provided with `original_value` */
value: 100_000_000,
},
25_000_00,
);
});

it("should use the backend approximation for the corresponding unit only when provided via CurrencyAmountPreferenceObj", () => {
const currencyMap = mapCurrencyAmount(
{
original_value: 147,
original_unit: CurrencyUnit.SATOSHI,
preferred_currency_unit: CurrencyUnit.USD,
preferred_currency_value_approx: 1_234_56,
},
25_000_00,
);
expect(currencyMap.btc).toBe(0.00000147);
expect(currencyMap.USD).toBe(1_234_56);
expect(currencyMap.sats).toBe(147);
expect(currencyMap.msats).toBe(147_000);
expect(currencyMap.formatted.btc).toBe("0");
expect(currencyMap.formatted.USD).toBe("$1,234.56");
expect(currencyMap.formatted.sats).toBe("147");
expect(currencyMap.formatted.msats).toBe("147,000");
});

it("should return the expected value for a SDKCurrencyAmountType and use backend approximation for the corresponding unit", () => {
const currencyMap = mapCurrencyAmount(
{
originalValue: 147,
originalUnit: CurrencyUnit.SATOSHI,
preferredCurrencyUnit: CurrencyUnit.USD,
preferredCurrencyValueApprox: 1_234_56,
preferredCurrencyValueRounded: 1_234_56,
},
25_000_00,
);
expect(currencyMap.btc).toBe(0.00000147);
expect(currencyMap.USD).toBe(1_234_56);
expect(currencyMap.sats).toBe(147);
expect(currencyMap.msats).toBe(147_000);
expect(currencyMap.formatted.btc).toBe("0");
expect(currencyMap.formatted.USD).toBe("$1,234.56");
expect(currencyMap.formatted.sats).toBe("147");
expect(currencyMap.formatted.msats).toBe("147,000");
});
});

describe("localeToCurrencySymbol", () => {
Expand Down

0 comments on commit 1856df2

Please sign in to comment.