From faec7ae06da0260ccece3b6280347467f9e7c104 Mon Sep 17 00:00:00 2001 From: Marcelo Salloum dos Santos Date: Thu, 2 Jun 2022 21:55:32 -0300 Subject: [PATCH] SEP-38: update SEP-38 tests based on stellar/stellar-protocol#1204 (#86) ### What Update SEP-38 tests based on stellar/stellar-protocol#1204. The changes include: * SEP-38 `GET /price` and `POST /quote` now require the mandatory `context` request parameter. * SEP-38 `GET /price` and `GET|POST /quote` now return the mandatory response parameters `total_price` and `fee`. * The updated formulas from [SEP38#price-formulas](https://github.com/stellar/stellar-protocol/blob/faa99165050dcd44a9e0f700c3d019258d8b4321/ecosystem/sep-0038.md#price-formulas) are now being validated. --- @stellar/anchor-tests/package.json | 2 +- @stellar/anchor-tests/src/schemas/sep38.ts | 43 +++++++- .../anchor-tests/src/tests/sep38/postQuote.ts | 76 ++++++++----- .../anchor-tests/src/tests/sep38/price.ts | 102 +++++++++++++----- .../anchor-tests/src/tests/sep38/prices.ts | 6 +- .../anchor-tests/src/tests/sep38/tests.ts | 2 +- CHANGELOG.md | 9 ++ server/package.json | 2 +- 8 files changed, 187 insertions(+), 55 deletions(-) diff --git a/@stellar/anchor-tests/package.json b/@stellar/anchor-tests/package.json index 2d94989..05e2b38 100644 --- a/@stellar/anchor-tests/package.json +++ b/@stellar/anchor-tests/package.json @@ -1,6 +1,6 @@ { "name": "@stellar/anchor-tests", - "version": "0.4.1", + "version": "0.5.0", "description": "stellar-anchor-tests is a library and command line interface for testing Stellar anchors.", "main": "./lib/index.js", "types": "./lib/index.d.ts", diff --git a/@stellar/anchor-tests/src/schemas/sep38.ts b/@stellar/anchor-tests/src/schemas/sep38.ts index 6635a12..90cdf03 100644 --- a/@stellar/anchor-tests/src/schemas/sep38.ts +++ b/@stellar/anchor-tests/src/schemas/sep38.ts @@ -76,12 +76,47 @@ export const pricesSchema = { required: ["buy_assets"], }; +const rateFeeSchema = { + type: "object", + properties: { + total: { + type: "string" + }, + asset: { + type: "string" + }, + details: { + type: "array", + items: { + type: "object", + properties: { + name: { + type: "string" + }, + description: { + type: "string" + }, + amount: { + type: "string" + }, + }, + required: ["name", "amount"], + } + } + }, + required: ["total", "asset"] +} + export const priceSchema = { type: "object", properties: { price: { type: "string", }, + total_price: { + type: "string", + }, + fee: rateFeeSchema, sell_amount: { type: "string", }, @@ -90,7 +125,7 @@ export const priceSchema = { }, }, additionalProperties: false, - required: ["price", "sell_amount", "buy_amount"], + required: ["price", "total_price", "sell_amount", "buy_amount", "fee"], }; export const quoteSchema = { @@ -106,6 +141,10 @@ export const quoteSchema = { price: { type: "string", }, + total_price: { + type: "string", + }, + fee: rateFeeSchema, sell_asset: { type: "string", }, @@ -124,6 +163,8 @@ export const quoteSchema = { "id", "expires_at", "price", + "total_price", + "fee", "sell_asset", "buy_asset", "sell_amount", diff --git a/@stellar/anchor-tests/src/tests/sep38/postQuote.ts b/@stellar/anchor-tests/src/tests/sep38/postQuote.ts index 4b82db2..b4fa72c 100644 --- a/@stellar/anchor-tests/src/tests/sep38/postQuote.ts +++ b/@stellar/anchor-tests/src/tests/sep38/postQuote.ts @@ -42,6 +42,7 @@ export const requiresJwt: Test = { sell_asset: this.context.expects.sep38StellarAsset, buy_asset: this.context.expects.sep38OffChainAsset, sell_amount: "100", + context: "sep31", }; if (this.context.expects.sep38BuyDeliveryMethod) requestBody.buy_delivery_method = @@ -162,6 +163,7 @@ export const canCreateQuote: Test = { sell_asset: this.context.expects.sep38StellarAsset, buy_asset: this.context.expects.sep38OffChainAsset, sell_amount: "100", + context: "sep31", }; if (this.context.expects.sep38OffChainAssetBuyDeliveryMethod !== undefined) requestBody.buy_delivery_method = @@ -243,41 +245,65 @@ export const amountsAreValid: Test = { INVALID_AMOUNTS: { name: "amounts and price don't match", text(args: any): string { - return `The amounts returned in the response do not add up. ${args.buyAmount} * ${args.price} != ${args.sellAmount}`; + return `The amounts returned in the response do not add up. ${args.message}`; }, links: { - "POST /quote Response": - "https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0038.md#response-3", + "Price formulas": + "https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0038.md#price-formulas", }, }, ...genericFailures, }, async run(_config: Config): Promise { const result: Result = { networkCalls: [] }; - const roundingMultiplier = Math.pow( - 10, - Number(this.context.expects.sep38OffChainAssetDecimals), - ); - if ( - Math.round( - (Number(this.context.expects.sep38QuoteResponseObj.sell_amount) / - Number(this.context.expects.sep38QuoteResponseObj.price)) * - roundingMultiplier, - ) / - roundingMultiplier !== - Math.round( - Number(this.context.expects.sep38QuoteResponseObj.buy_amount) * - roundingMultiplier, - ) / - roundingMultiplier - ) { - result.failure = makeFailure(this.failureModes.INVALID_AMOUNTS, { - buyAmount: this.context.expects.sep38QuoteResponseObj.buy_amount, - sellAmount: this.context.expects.sep38QuoteResponseObj.sell_amount, - price: this.context.expects.sep38QuoteResponseObj.price, - }); + const decimals = Number(this.context.expects.sep38OffChainAssetDecimals); + const roundingMultiplier = Math.pow(10, decimals); + + // validate total_price + // sell_amount / total_price = buy_amount + const sellAmount = Number(this.context.expects.sep38QuoteResponseObj.sell_amount); + const buyAmount = Number(this.context.expects.sep38QuoteResponseObj.buy_amount); + const totalPrice = Number(this.context.expects.sep38QuoteResponseObj.total_price) + const totalPriceMatchesAmounts = + Math.round((sellAmount / totalPrice) * roundingMultiplier) / roundingMultiplier + === Math.round(buyAmount * roundingMultiplier) / roundingMultiplier; + if (!totalPriceMatchesAmounts) { + var message = `\nFormula "sell_amount = buy_amount * total_price" is not true for the number of decimals (${decimals}) required:` + message += `\n\t${sellAmount} != ${buyAmount} * ${totalPrice}` + result.failure = makeFailure(this.failureModes.INVALID_AMOUNTS, { message }); return result; } + + // validate price + const sellAsset = this.context.expects.sep38QuoteResponseObj.sell_asset; + const buyAsset = this.context.expects.sep38QuoteResponseObj.buy_asset; + const feeAsset = this.context.expects.sep38QuoteResponseObj.fee.asset; + const price = this.context.expects.sep38QuoteResponseObj.price; + const feeTotal = this.context.expects.sep38QuoteResponseObj.fee.total; + if (feeAsset === sellAsset) { + // sell_amount - fee = price * buy_amount // when `fee` is in `sell_asset` + const priceAndFeeMatchAmounts = + Math.round((sellAmount - feeTotal) * roundingMultiplier) / roundingMultiplier + === Math.round(price * buyAmount * roundingMultiplier) / roundingMultiplier; + if (!priceAndFeeMatchAmounts) { + var message = `\nFormula "sell_amount - fee = price * buy_amount" is not true for the number of decimals (${decimals}) required:` + message += `\n\t${sellAmount} - ${feeTotal} != ${price} * ${buyAmount}` + result.failure = makeFailure(this.failureModes.INVALID_AMOUNTS, { message }); + return result; + } + } else if (feeAsset === buyAsset) { + // sell_amount / price = buy_amount + fee // when `fee` is in `buy_asset` + const priceAndFeeMatchAmounts = + Math.round((sellAmount / price) * roundingMultiplier) / roundingMultiplier + === Math.round((buyAmount + feeTotal) * roundingMultiplier) / roundingMultiplier; + if (!priceAndFeeMatchAmounts) { + var message = `\nFormula "sell_amount / price = (buy_amount + fee)" is not true for the number of decimals (${decimals}) required:` + message += `\n\t${sellAmount} / ${price} != ${buyAmount} + ${feeTotal}` + result.failure = makeFailure(this.failureModes.INVALID_AMOUNTS, { message }); + return result; + } + } + return result; }, }; diff --git a/@stellar/anchor-tests/src/tests/sep38/price.ts b/@stellar/anchor-tests/src/tests/sep38/price.ts index 278656d..41ce951 100644 --- a/@stellar/anchor-tests/src/tests/sep38/price.ts +++ b/@stellar/anchor-tests/src/tests/sep38/price.ts @@ -43,6 +43,9 @@ export const returnsValidResponse: Test = { sep38SellAmount: undefined, sep38BuyAmount: undefined, sep38Price: undefined, + sep38TotalPrice: undefined, + sep38FeeTotal: undefined, + sep38FeeAsset: undefined, }, }, failureModes: { @@ -78,6 +81,7 @@ export const returnsValidResponse: Test = { sell_asset: this.context.expects.sep38StellarAsset, buy_asset: this.context.expects.sep38OffChainAsset, sell_amount: "100", + context: "sep31", }; if (this.context.expects.sep38OffChainAssetBuyDeliveryMethod !== undefined) requestBody.buy_delivery_method = @@ -101,6 +105,7 @@ export const returnsValidResponse: Test = { result, "application/json", ); + if (!priceResponse) return result; const validationResult = validate(priceResponse, priceSchema); if (validationResult.errors.length !== 0) { @@ -109,17 +114,31 @@ export const returnsValidResponse: Test = { }); return result; } + + if (!priceResponse.fee || !priceResponse.fee.asset) { + result.failure = makeFailure(this.failureModes.INVALID_SCHEMA, { + errors: "The response body from GET /price does not contain a fee asset.", + }); + return result; + } + if ( !Number(priceResponse.buy_amount) || !Number(priceResponse.sell_amount) || - !Number(priceResponse.price) + !Number(priceResponse.price) || + !Number(priceResponse.total_price) || + !Number(priceResponse.fee.total) ) { result.failure = makeFailure(this.failureModes.INVALID_NUMBER); return result; } + this.context.provides.sep38SellAmount = Number(priceResponse.sell_amount); this.context.provides.sep38BuyAmount = Number(priceResponse.buy_amount); this.context.provides.sep38Price = Number(priceResponse.price); + this.context.provides.sep38TotalPrice = Number(priceResponse.total_price); + this.context.provides.sep38FeeTotal = Number(priceResponse.fee.total); + this.context.provides.sep38FeeAsset = priceResponse.fee.asset; return result; }, }; @@ -134,7 +153,12 @@ export const amountsAreValid: Test = { sep38SellAmount: undefined, sep38BuyAmount: undefined, sep38Price: undefined, + sep38TotalPrice: undefined, + sep38FeeTotal: undefined, + sep38FeeAsset: undefined, sep38OffChainAssetDecimals: undefined, + sep38StellarAsset: undefined, // sell_asset + sep38OffChainAsset: undefined, // buy_asset }, provides: {}, }, @@ -142,40 +166,66 @@ export const amountsAreValid: Test = { INVALID_AMOUNTS: { name: "amounts and price don't match", text(args: any): string { - return `The amounts returned in the response do not add up. ${args.buyAmount} * ${args.price} != ${args.sellAmount}`; + return `The amounts returned in the response do not add up. ${args.message}`; }, links: { - "GET /price Response": - "https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0038.md#response-2", + "Price formulas": + "https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0038.md#price-formulas", }, }, ...genericFailures, }, + async run(_config: Config): Promise { const result: Result = { networkCalls: [] }; - const roundingMultiplier = Math.pow( - 10, - Number(this.context.expects.sep38OffChainAssetDecimals), - ); - if ( - Math.round( - (Number(this.context.expects.sep38SellAmount) / - Number(this.context.expects.sep38Price)) * - roundingMultiplier, - ) / - roundingMultiplier !== - Math.round( - Number(this.context.expects.sep38BuyAmount) * roundingMultiplier, - ) / - roundingMultiplier - ) { - result.failure = makeFailure(this.failureModes.INVALID_AMOUNTS, { - buyAmount: this.context.expects.sep38BuyAmount, - sellAmount: this.context.expects.sep38SellAmount, - price: this.context.expects.sep38Price, - }); + const decimals = Number(this.context.expects.sep38OffChainAssetDecimals); + const roundingMultiplier = Math.pow(10, decimals); + + // validate total_price + // sell_amount / total_price = buy_amount + const sellAmount = Number(this.context.expects.sep38SellAmount); + const buyAmount = Number(this.context.expects.sep38BuyAmount); + const totalPrice = Number(this.context.expects.sep38TotalPrice) + const totalPriceMatchesAmounts = + Math.round((sellAmount / totalPrice) * roundingMultiplier) / roundingMultiplier + === Math.round(buyAmount * roundingMultiplier) / roundingMultiplier; + if (!totalPriceMatchesAmounts) { + var message = `\nFormula "sell_amount = buy_amount * total_price" is not true for the number of decimals (${decimals}) required:` + message += `\n\t${sellAmount} != ${buyAmount} * ${totalPrice}` + result.failure = makeFailure(this.failureModes.INVALID_AMOUNTS, { message }); return result; } + + // validate price + const sellAsset = this.context.expects.sep38StellarAsset; + const buyAsset = this.context.expects.sep38OffChainAsset; + const feeAsset = this.context.expects.sep38FeeAsset; + const price = this.context.expects.sep38Price; + const feeTotal = this.context.expects.sep38FeeTotal; + if (feeAsset === sellAsset) { + // sell_amount - fee = price * buy_amount // when `fee` is in `sell_asset` + const priceAndFeeMatchAmounts = + Math.round((sellAmount - feeTotal) * roundingMultiplier) / roundingMultiplier + === Math.round(price * buyAmount * roundingMultiplier) / roundingMultiplier; + if (!priceAndFeeMatchAmounts) { + var message = `\nFormula "sell_amount - fee = price * buy_amount" is not true for the number of decimals (${decimals}) required:` + message += `\n\t${sellAmount} - ${feeTotal} != ${price} * ${buyAmount}` + result.failure = makeFailure(this.failureModes.INVALID_AMOUNTS, { message }); + return result; + } + } else if (feeAsset === buyAsset) { + // sell_amount / price = buy_amount + fee // when `fee` is in `buy_asset` + const priceAndFeeMatchAmounts = + Math.round((sellAmount / price) * roundingMultiplier) / roundingMultiplier + === Math.round((buyAmount + feeTotal) * roundingMultiplier) / roundingMultiplier; + if (!priceAndFeeMatchAmounts) { + var message = `\nFormula "sell_amount / price = (buy_amount + fee)" is not true for the number of decimals (${decimals}) required:` + message += `\n\t${sellAmount} / ${price} != ${buyAmount} + ${feeTotal}` + result.failure = makeFailure(this.failureModes.INVALID_AMOUNTS, { message }); + return result; + } + } + return result; }, }; @@ -204,6 +254,7 @@ export const acceptsBuyAmounts: Test = { sell_asset: this.context.expects.sep38StellarAsset, buy_asset: this.context.expects.sep38OffChainAsset, buy_amount: "100", + context: "sep31", }; if (this.context.expects.sep38OffChainAssetBuyDeliveryMethod !== undefined) requestBody.buy_delivery_method = @@ -283,6 +334,7 @@ export const deliveryMethodIsOptional: Test = { sell_asset: this.context.expects.sep38StellarAsset, buy_asset: this.context.expects.sep38OffChainAsset, sell_amount: "100", + context: "sep31", }; const networkCall: NetworkCall = { request: new Request( diff --git a/@stellar/anchor-tests/src/tests/sep38/prices.ts b/@stellar/anchor-tests/src/tests/sep38/prices.ts index 291b9e1..dbb7986 100644 --- a/@stellar/anchor-tests/src/tests/sep38/prices.ts +++ b/@stellar/anchor-tests/src/tests/sep38/prices.ts @@ -121,7 +121,11 @@ export const hasValidSchema: Test = { } for (const asset of pricesResponse.buy_assets) { const parts = asset.asset.split(":"); - if (parts.length !== 2 || parts[0] !== "iso4217") { + + const notValidFiat = parts.length === 2 && parts[0] !== "iso4217"; + const notValidStellar = parts.length === 3 && parts[0] !== "stellar"; + const notValidAtAll = parts.length < 2 || parts.length > 3; + if (notValidFiat || notValidStellar || notValidAtAll) { result.failure = makeFailure(this.failureModes.INVALID_ASSET_VALUE, { asset: asset.asset, }); diff --git a/@stellar/anchor-tests/src/tests/sep38/tests.ts b/@stellar/anchor-tests/src/tests/sep38/tests.ts index 119f599..65dfb5f 100644 --- a/@stellar/anchor-tests/src/tests/sep38/tests.ts +++ b/@stellar/anchor-tests/src/tests/sep38/tests.ts @@ -1,7 +1,7 @@ import { default as tomlTests } from "./toml"; import { default as infoTests } from "./info"; -import { default as priceTests } from "./price"; import { default as pricesTests } from "./prices"; +import { default as priceTests } from "./price"; import { default as postQuoteTests } from "./postQuote"; import { default as getQuoteTests } from "./getQuote"; diff --git a/CHANGELOG.md b/CHANGELOG.md index f29863b..8039ff7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ This changelog documents all releases and included changes to the @stellar/ancho A breaking change will get clearly marked in this log. +## [v0.5.0](https://github.com/stellar/stellar-anchor-tests/compare/v0.4.1...v0.5.0) + +### Update + +- Update SEP-38 tests based on [stellar/stellar-protocol#1204](https://github.com/stellar/stellar-protocol/pull/1204). The changes include: + - SEP-38 `GET /price` and `POST /quote` now require the mandatory `context` request parameter. + - SEP-38 `GET /price` and `GET|POST /quote` now return the mandatory response parameters `total_price` and `fee`. + - The SEP-38 amounts formula validation was updated based on the new version from [SEP38#price-formulas](https://github.com/stellar/stellar-protocol/blob/faa99165050dcd44a9e0f700c3d019258d8b4321/ecosystem/sep-0038.md#price-formulas). + ## [v0.4.1](https://github.com/stellar/stellar-anchor-tests/compare/v0.4.0...v0.4.1) ### Update diff --git a/server/package.json b/server/package.json index 9ecc6bc..66e508f 100644 --- a/server/package.json +++ b/server/package.json @@ -24,7 +24,7 @@ }, "dependencies": { "@types/node": "^14.14.41", - "@stellar/anchor-tests": "0.4.1", + "@stellar/anchor-tests": "0.5.0", "express": "^4.17.1", "socket.io": "^4.1.2", "tslib": "^2.2.0",