From 43a330c5f401d3a6da296b087681a6eb5e47ec4b Mon Sep 17 00:00:00 2001 From: Jakub Dzikowski Date: Wed, 7 Oct 2026 13:31:06 +0200 Subject: [PATCH] Fix: Check an oracle quote against the transfer it prices Require a bridge quote to name the same token, fungible flag, and destination as the transfer, and reject a cross rate that does not match its quoted pair. Co-authored-by: Cursor --- chain-api/src/types/oracle.spec.ts | 11 ++++ chain-api/src/types/oracle.ts | 31 ++++++++- chaincode/src/fees/feeGateImplementations.ts | 29 +++++++++ chaincode/src/fees/oracleQuoteBinding.spec.ts | 65 +++++++++++++++++++ 4 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 chaincode/src/fees/oracleQuoteBinding.spec.ts diff --git a/chain-api/src/types/oracle.spec.ts b/chain-api/src/types/oracle.spec.ts index bb777e741a..52b0bd723e 100644 --- a/chain-api/src/types/oracle.spec.ts +++ b/chain-api/src/types/oracle.spec.ts @@ -93,6 +93,17 @@ describe("oracle.ts", () => { mockAssertionDto.crossRate = new BigNumber("42"); expect(mockAssertionDto.validateCrossRate).toThrow(); + + await expect( + createValidSubmitDTO(OraclePriceCrossRateAssertionDto, { + oracle: mockOracle, + identity: mockOracleIdentity, + baseTokenCrossRate: tonUsdPriceAssertion, + quoteTokenCrossRate: galaUsdPriceAssertion, + externalCrossRateToken: usdDetails, + crossRate: new BigNumber("42") + }) + ).rejects.toThrow(/cross rate does not match/); }); test("FetchOracleDefinitionsDto accepts optional name filter", async () => { diff --git a/chain-api/src/types/oracle.ts b/chain-api/src/types/oracle.ts index 7f450b39b2..3ea2359553 100644 --- a/chain-api/src/types/oracle.ts +++ b/chain-api/src/types/oracle.ts @@ -25,8 +25,12 @@ import { IsString, Max, Min, + Validate, ValidateIf, - ValidateNested + ValidateNested, + ValidationArguments, + ValidatorConstraint, + ValidatorConstraintInterface } from "class-validator"; import { JSONSchema } from "class-validator-jsonschema"; @@ -235,6 +239,24 @@ export class FetchOraclePriceAssertionsResponse extends ChainCallDTO { bookmark?: string | undefined; } +@ValidatorConstraint({ name: "crossRateMatchesQuote", async: false }) +class CrossRateMatchesQuoteConstraint implements ValidatorConstraintInterface { + validate(_value: unknown, args: ValidationArguments): boolean { + const assertion = args.object as OraclePriceCrossRateAssertionDto; + try { + assertion.validateCrossRateTokenKeys(); + assertion.validateCrossRate(); + return true; + } catch { + return false; + } + } + + defaultMessage(): string { + return "cross rate does not match the quoted token pair"; + } +} + @JSONSchema({ description: "Cross Rate Exchange price assertion. E.g. compare $GALA to $ETH via price in $USD for each." }) @@ -290,6 +312,7 @@ export class OraclePriceCrossRateAssertionDto extends SubmitCallDTO { @JSONSchema({ description: "Cross rate for baseToken and quoteToken, calculated from the crossRateToken exchange rates." }) + @Validate(CrossRateMatchesQuoteConstraint) @BigNumberProperty() crossRate: BigNumber; @@ -461,6 +484,12 @@ export class OracleBridgeFeeAssertionDto extends SubmitCallDTO { @Max(32) public galaDecimals: number; + @JSONSchema({ + description: "Destination chain this quote prices." + }) + @IsInt() + public destinationChainId: number; + @JSONSchema({ description: "The token requested to bridge. Token Class used to query the estimated " + "transaction fee units." diff --git a/chaincode/src/fees/feeGateImplementations.ts b/chaincode/src/fees/feeGateImplementations.ts index c97d8b76ee..3cd07fd837 100644 --- a/chaincode/src/fees/feeGateImplementations.ts +++ b/chaincode/src/fees/feeGateImplementations.ts @@ -43,6 +43,7 @@ import { SetTokenInstanceMetadataDto, TerminateTokenSwapDto, TokenClassKey, + TokenInstance, TokenInstanceKey, TokenMintConfiguration, TransferTokenDto, @@ -224,6 +225,32 @@ export async function mintTokenWithAllowanceFeeGate( }); } +export function assertOracleQuoteMatchesTransfer( + quote: OracleBridgeFeeAssertionDto, + transfer: { destinationChainId: number; tokenInstance: TokenInstanceKey } +): void { + const quoted = quote.bridgeToken; + const requested = transfer.tokenInstance; + const sameClass = + quoted.collection === requested.collection && + quoted.category === requested.category && + quoted.type === requested.type && + quoted.additionalKey === requested.additionalKey; + + if (!sameClass) { + throw new ValidationFailedError("Oracle quote is for a different token"); + } + + const requestedIsNonFungible = !TokenInstance.isFungible(requested.instance); + if (quote.bridgeTokenIsNonFungible !== requestedIsNonFungible) { + throw new ValidationFailedError("Oracle quote does not match whether the token is non-fungible"); + } + + if (quote.destinationChainId !== transfer.destinationChainId) { + throw new ValidationFailedError("Oracle quote is for a different destination chain"); + } +} + export interface RequestTokenBridgeOutFeeGateParams { destinationChainId: number; tokenInstance: TokenInstanceKey; @@ -311,6 +338,8 @@ export async function requestTokenBridgeOutFeeGate( ); } + assertOracleQuoteMatchesTransfer(oracleAssertion, dto); + const gasFeeQuantity: BigNumber = oracleAssertion.estimatedTotalTxFeeInGala; // standard GalaChain usage based fees optionally defined with FeeCodeDefinition(s) diff --git a/chaincode/src/fees/oracleQuoteBinding.spec.ts b/chaincode/src/fees/oracleQuoteBinding.spec.ts new file mode 100644 index 0000000000..e550d4a6f2 --- /dev/null +++ b/chaincode/src/fees/oracleQuoteBinding.spec.ts @@ -0,0 +1,65 @@ +/* + * Copyright (c) Gala Games Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { OracleBridgeFeeAssertionDto, TokenClassKey, TokenInstance, TokenInstanceKey } from "@gala-chain/api"; +import BigNumber from "bignumber.js"; + +import { assertOracleQuoteMatchesTransfer } from "./feeGateImplementations"; + +const token = { + collection: "GALA", + category: "Unit", + type: "none", + additionalKey: "none", + instance: TokenInstance.FUNGIBLE_TOKEN_INSTANCE +} as TokenInstanceKey; + +function quote(overrides: Partial = {}): OracleBridgeFeeAssertionDto { + return { + destinationChainId: 2, + bridgeToken: token, + bridgeTokenIsNonFungible: false, + ...overrides + } as OracleBridgeFeeAssertionDto; +} + +describe("oracle quote binding", () => { + it("accepts a quote for the same token, fungible flag, and destination", () => { + expect(() => + assertOracleQuoteMatchesTransfer(quote(), { destinationChainId: 2, tokenInstance: token }) + ).not.toThrow(); + }); + + it("rejects a quote for a different token, fungible flag, or destination", () => { + const nft = { ...token, instance: new BigNumber("1") } as TokenInstanceKey; + + expect(() => + assertOracleQuoteMatchesTransfer(quote({ destinationChainId: 1001 }), { + destinationChainId: 2, + tokenInstance: token + }) + ).toThrow(/destination/); + + expect(() => + assertOracleQuoteMatchesTransfer(quote(), { destinationChainId: 2, tokenInstance: nft }) + ).toThrow(/non-fungible/); + + expect(() => + assertOracleQuoteMatchesTransfer( + quote({ bridgeToken: { ...token, collection: "OTHER" } as TokenClassKey }), + { destinationChainId: 2, tokenInstance: token } + ) + ).toThrow(/different token/); + }); +});