From f04481429cae0dab9ab4309fe28b9293eb59ad01 Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Fri, 18 Oct 2024 10:04:34 -0600 Subject: [PATCH] makes getIsToken spec return false if spec is not parseable --- src/helper/soroban-rpc/index.test.ts | 30 +- src/helper/soroban-rpc/index.ts | 649 +-------------------------- src/helper/soroban-rpc/network.ts | 235 ++++++++++ src/helper/soroban-rpc/token.ts | 426 ++++++++++++++++++ src/route/index.ts | 22 +- src/service/mercury/index.test.ts | 36 +- 6 files changed, 709 insertions(+), 689 deletions(-) create mode 100644 src/helper/soroban-rpc/network.ts create mode 100644 src/helper/soroban-rpc/token.ts diff --git a/src/helper/soroban-rpc/index.test.ts b/src/helper/soroban-rpc/index.test.ts index adfc610..5282351 100644 --- a/src/helper/soroban-rpc/index.test.ts +++ b/src/helper/soroban-rpc/index.test.ts @@ -1,12 +1,11 @@ import { xdr } from "stellar-sdk"; -import { base64regex } from "../test-helper"; -import { - getLedgerKeyContractCode, - getLedgerKeyWasmId, - isTokenSpec, - parseWasmXdr, -} from "."; +import { base64regex, testLogger } from "../test-helper"; +import * as networkHelpers from "./network"; +import { getIsTokenSpec, isTokenSpec } from "./token"; + +const { getLedgerKeyContractCode, getLedgerKeyWasmId, parseWasmXdr } = + networkHelpers; describe("Soroban RPC helpers", () => { const CONTRACT_ID = @@ -26,7 +25,7 @@ describe("Soroban RPC helpers", () => { }); it("will throw when it fails to get ledger key", () => { expect(() => - getLedgerKeyContractCode("not contract ID", "TESTNET") + getLedgerKeyContractCode("not contract ID", "TESTNET"), ).toThrowError(); }); }); @@ -66,4 +65,19 @@ describe("Soroban RPC helpers", () => { expect(isSep41).toBeFalsy(); }); }); + + describe("getIsTokenSpec", () => { + afterAll(() => { + jest.resetModules(); + }); + it("will return false when the spec cannot be parsed", async () => { + jest.spyOn(networkHelpers, "getContractSpec").mockImplementation(() => { + return Promise.resolve({ result: { notDefinitions: {} }, error: null }); + }); + + const isSep41 = await getIsTokenSpec("contractId", "TESTNET", testLogger); + + expect(isSep41).toBeFalsy(); + }); + }); }); diff --git a/src/helper/soroban-rpc/index.ts b/src/helper/soroban-rpc/index.ts index a28531d..688f7bd 100644 --- a/src/helper/soroban-rpc/index.ts +++ b/src/helper/soroban-rpc/index.ts @@ -1,647 +1,2 @@ -import * as StellarSdkNext from "stellar-sdk-next"; -import * as StellarSdk from "stellar-sdk"; -import { XdrReader } from "@stellar/js-xdr"; -import { NetworkNames } from "../validate"; -import { ERROR } from "../error"; -import { Logger } from "pino"; -import { getSdk } from "../stellar"; - -const TOKEN_SPEC_DEFINITIONS: { [index: string]: any } = { - allowance: { - properties: { - args: { - additionalProperties: false, - properties: { - from: { - $ref: "#/definitions/Address", - }, - spender: { - $ref: "#/definitions/Address", - }, - }, - type: "object", - required: ["from", "spender"], - }, - }, - additionalProperties: false, - }, - approve: { - properties: { - args: { - additionalProperties: false, - properties: { - from: { - $ref: "#/definitions/Address", - }, - spender: { - $ref: "#/definitions/Address", - }, - amount: { - $ref: "#/definitions/I128", - }, - expiration_ledger: { - $ref: "#/definitions/U32", - }, - }, - type: "object", - required: ["from", "spender", "amount", "expiration_ledger"], - }, - }, - additionalProperties: false, - }, - balance: { - properties: { - args: { - additionalProperties: false, - properties: { - id: { - $ref: "#/definitions/Address", - }, - }, - type: "object", - required: ["id"], - }, - }, - additionalProperties: false, - }, - transfer: { - properties: { - args: { - additionalProperties: false, - properties: { - from: { - $ref: "#/definitions/Address", - }, - to: { - $ref: "#/definitions/Address", - }, - amount: { - $ref: "#/definitions/I128", - }, - }, - type: "object", - required: ["from", "to", "amount"], - }, - }, - additionalProperties: false, - }, - transfer_from: { - properties: { - args: { - additionalProperties: false, - properties: { - spender: { - $ref: "#/definitions/Address", - }, - from: { - $ref: "#/definitions/Address", - }, - to: { - $ref: "#/definitions/Address", - }, - amount: { - $ref: "#/definitions/I128", - }, - }, - type: "object", - required: ["spender", "from", "to", "amount"], - }, - }, - additionalProperties: false, - }, - burn: { - properties: { - args: { - additionalProperties: false, - properties: { - from: { - $ref: "#/definitions/Address", - }, - amount: { - $ref: "#/definitions/I128", - }, - }, - type: "object", - required: ["from", "amount"], - }, - }, - additionalProperties: false, - }, - burn_from: { - properties: { - args: { - additionalProperties: false, - properties: { - spender: { - $ref: "#/definitions/Address", - }, - from: { - $ref: "#/definitions/Address", - }, - amount: { - $ref: "#/definitions/I128", - }, - }, - type: "object", - required: ["spender", "from", "amount"], - }, - }, - additionalProperties: false, - }, - decimals: { - properties: { - args: { - additionalProperties: false, - properties: {}, - type: "object", - }, - }, - additionalProperties: false, - }, - name: { - properties: { - args: { - additionalProperties: false, - properties: {}, - type: "object", - }, - }, - additionalProperties: false, - }, - symbol: { - properties: { - args: { - additionalProperties: false, - properties: {}, - type: "object", - }, - }, - additionalProperties: false, - }, -}; - -const SOROBAN_RPC_URLS: { [key in keyof typeof StellarSdk.Networks]?: string } = - { - PUBLIC: - "http://soroban-rpc-pubnet-prd.soroban-rpc-pubnet-prd.svc.cluster.local:8000", - TESTNET: "https://soroban-testnet.stellar.org/", - FUTURENET: "https://rpc-futurenet.stellar.org/", - }; - -const getServer = async (network: NetworkNames) => { - const serverUrl = SOROBAN_RPC_URLS[network]; - if (!serverUrl) { - throw new Error(ERROR.UNSUPPORTED_NETWORK); - } - - const Sdk = getSdk(StellarSdkNext.Networks[network]); - - return new Sdk.SorobanRpc.Server(serverUrl, { - allowHttp: serverUrl.startsWith("http://"), - }); -}; - -const getTxBuilder = async ( - pubKey: string, - network: NetworkNames, - server: StellarSdk.SorobanRpc.Server | StellarSdkNext.SorobanRpc.Server -) => { - const Sdk = getSdk(StellarSdkNext.Networks[network]); - const sourceAccount = await server.getAccount(pubKey); - return new Sdk.TransactionBuilder(sourceAccount, { - fee: StellarSdk.BASE_FEE, - networkPassphrase: StellarSdk.Networks[network], - }); -}; - -const simulateTx = async ( - tx: StellarSdk.Transaction< - StellarSdk.Memo, - StellarSdk.Operation[] - >, - server: StellarSdk.SorobanRpc.Server | StellarSdkNext.SorobanRpc.Server, - networkPassphrase: StellarSdk.Networks -): Promise => { - const Sdk = getSdk(networkPassphrase); - const simulatedTX = await server.simulateTransaction(tx); - if ( - Sdk.SorobanRpc.Api.isSimulationSuccess(simulatedTX) && - simulatedTX.result - ) { - return Sdk.scValToNative(simulatedTX.result.retval); - } - - if (Sdk.SorobanRpc.Api.isSimulationError(simulatedTX)) { - throw new Error(simulatedTX.error); - } - - throw new Error(ERROR.FAILED_TO_SIM); -}; - -const getTokenDecimals = async ( - contractId: string, - server: StellarSdk.SorobanRpc.Server | StellarSdkNext.SorobanRpc.Server, - builder: StellarSdk.TransactionBuilder, - network: NetworkNames -) => { - const Sdk = getSdk(StellarSdkNext.Networks[network]); - const contract = new Sdk.Contract(contractId); - - const tx = builder - .addOperation(contract.call("decimals")) - .setTimeout(Sdk.TimeoutInfinite) - .build(); - - const result = await simulateTx( - tx, - server, - StellarSdkNext.Networks[network] - ); - return result; -}; - -const getTokenName = async ( - contractId: string, - server: StellarSdk.SorobanRpc.Server | StellarSdkNext.SorobanRpc.Server, - builder: StellarSdk.TransactionBuilder, - network: NetworkNames -) => { - const Sdk = getSdk(StellarSdkNext.Networks[network]); - const contract = new Sdk.Contract(contractId); - - const tx = builder - .addOperation(contract.call("name")) - .setTimeout(Sdk.TimeoutInfinite) - .build(); - - const result = await simulateTx( - tx, - server, - StellarSdkNext.Networks[network] - ); - return result; -}; - -const getTokenSymbol = async ( - contractId: string, - server: StellarSdk.SorobanRpc.Server | StellarSdkNext.SorobanRpc.Server, - builder: StellarSdk.TransactionBuilder, - network: NetworkNames -) => { - const Sdk = getSdk(StellarSdkNext.Networks[network]); - const contract = new Sdk.Contract(contractId); - - const tx = builder - .addOperation(contract.call("symbol")) - .setTimeout(Sdk.TimeoutInfinite) - .build(); - - const result = await simulateTx( - tx, - server, - StellarSdkNext.Networks[network] - ); - return result; -}; - -const getTokenBalance = async ( - contractId: string, - params: StellarSdk.xdr.ScVal[], - server: StellarSdk.SorobanRpc.Server | StellarSdkNext.SorobanRpc.Server, - builder: StellarSdk.TransactionBuilder, - network: NetworkNames -) => { - const Sdk = getSdk(StellarSdkNext.Networks[network]); - const contract = new Sdk.Contract(contractId); - - const tx = builder - .addOperation(contract.call("balance", ...params)) - .setTimeout(Sdk.TimeoutInfinite) - .build(); - - const result = await simulateTx( - tx, - server, - StellarSdkNext.Networks[network] - ); - return result; -}; - -const buildTransfer = ( - contractId: string, - params: StellarSdk.xdr.ScVal[], - memo: string | undefined, - builder: StellarSdk.TransactionBuilder, - networkPassphrase: StellarSdk.Networks -) => { - const Sdk = getSdk(networkPassphrase); - const contract = new Sdk.Contract(contractId); - - const tx = builder - .addOperation(contract.call("transfer", ...params)) - .setTimeout(Sdk.TimeoutInfinite); - - if (memo) { - tx.addMemo(Sdk.Memo.text(memo)); - } - - return tx.build(); -}; - -// https://github.com/stellar/soroban-examples/blob/main/token/src/contract.rs -enum SorobanTokenInterface { - transfer = "transfer", - mint = "mint", -} - -const getOpArgs = ( - fnName: string, - args: StellarSdk.xdr.ScVal[], - network: NetworkNames -) => { - const Sdk = getSdk(StellarSdk.Networks[network]); - - let amount: number; - let from; - let to; - - switch (fnName) { - case SorobanTokenInterface.transfer: - from = Sdk.StrKey.encodeEd25519PublicKey( - args[0].address().accountId().ed25519() - ); - to = Sdk.StrKey.encodeEd25519PublicKey( - args[1].address().accountId().ed25519() - ); - amount = Sdk.scValToNative(args[2]).toString(); - break; - case SorobanTokenInterface.mint: - to = Sdk.StrKey.encodeEd25519PublicKey( - args[0].address().accountId().ed25519() - ); - amount = Sdk.scValToNative(args[1]).toString(); - break; - default: - amount = 0; - } - - return { from, to, amount }; -}; - -const getLedgerKeyContractCode = ( - contractId: string, - network: NetworkNames -) => { - const Sdk = getSdk(StellarSdkNext.Networks[network]); - const { Address, xdr } = Sdk; - - const ledgerKey = xdr.LedgerKey.contractData( - new xdr.LedgerKeyContractData({ - contract: new Address(contractId).toScAddress(), - key: xdr.ScVal.scvLedgerKeyContractInstance(), - durability: xdr.ContractDataDurability.persistent(), - }) - ); - return ledgerKey.toXDR("base64"); -}; - -const getExecutable = ( - contractLedgerEntryData: string, - network: NetworkNames -) => { - const Sdk = getSdk(StellarSdkNext.Networks[network]); - const { xdr } = Sdk; - return xdr.LedgerEntryData.fromXDR(contractLedgerEntryData, "base64") - .contractData() - .val() - .instance() - .executable(); -}; - -const getLedgerKeyWasmId = ( - executable: - | StellarSdk.xdr.ContractExecutable - | StellarSdkNext.xdr.ContractExecutable, - network: NetworkNames -) => { - const Sdk = getSdk(StellarSdkNext.Networks[network]); - const { xdr } = Sdk; - const contractCodeWasmHash = executable.wasmHash(); - const ledgerKey = xdr.LedgerKey.contractCode( - new xdr.LedgerKeyContractCode({ - hash: contractCodeWasmHash, - }) - ); - return ledgerKey.toXDR("base64"); -}; - -async function parseWasmXdr(xdrContents: string, network: NetworkNames) { - const Sdk = getSdk(StellarSdkNext.Networks[network]); - const { xdr, contract } = Sdk; - const wasmBuffer = xdr.LedgerEntryData.fromXDR(xdrContents, "base64") - .contractCode() - .code(); - const wasmModule = await WebAssembly.compile(wasmBuffer); - const reader = new XdrReader( - Buffer.from( - WebAssembly.Module.customSections(wasmModule, "contractspecv0")[0] - ) - ); - - const specs = []; - do { - specs.push(xdr.ScSpecEntry.read(reader)); - } while (!reader.eof); - const contractSpec = new contract.Spec(specs); - return contractSpec.jsonSchema(); -} - -const getLedgerEntries = async ( - entryKey: string, - rpcUrl: string, - id: number = new Date().getDate() -): Promise<{ - error: Error; - result: StellarSdk.SorobanRpc.Api.RawGetLedgerEntriesResponse; -}> => { - let requestBody = { - jsonrpc: "2.0", - id: id, - method: "getLedgerEntries", - params: { - keys: [entryKey], - }, - }; - - let res = await fetch(rpcUrl, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(requestBody), - }); - let json = await res.json(); - if (!res.ok) { - throw new Error(json); - } - return json; -}; - -const getIsTokenSpec = async ( - contractId: string, - network: NetworkNames, - logger: Logger -) => { - try { - const spec = await getContractSpec(contractId, network, logger); - if (spec.error) { - throw new Error(spec.error); - } - return { error: null, result: isTokenSpec(spec.result!) }; - } catch (error) { - logger.error(error); - return { error: "Unable to fetch token spec", result: null }; - } -}; - -const isTokenSpec = (spec: Record) => { - for (const tokenMethod of Object.keys(TOKEN_SPEC_DEFINITIONS)) { - const specMethod = spec.definitions[tokenMethod]; - if ( - !specMethod || - JSON.stringify(specMethod) !== - JSON.stringify(TOKEN_SPEC_DEFINITIONS[tokenMethod]) - ) { - return false; - } - } - return true; -}; - -const isSacContractExecutable = async ( - contractId: string, - network: NetworkNames -) => { - // verify the contract executable in the instance entry - // The SAC has a unique contract executable type - const Sdk = getSdk(StellarSdkNext.Networks[network]); - const { xdr } = Sdk; - const server = await getServer(network); - const instance = new Sdk.Contract(contractId).getFootprint(); - const ledgerKeyContractCode = instance.toXDR("base64"); - - const { entries } = await server.getLedgerEntries( - xdr.LedgerKey.fromXDR(ledgerKeyContractCode, "base64") - ); - - if (entries && entries.length) { - const parsed = entries[0].val; - const executable = parsed.contractData().val().instance().executable(); - - return ( - executable.switch().name === - xdr.ContractExecutableType.contractExecutableStellarAsset().name - ); - } - - return false; -}; - -const isSacContract = ( - name: string, - contractId: string, - network: StellarSdk.Networks -) => { - const Sdk = getSdk(network); - if (name.includes(":")) { - try { - return ( - new Sdk.Asset(...(name.split(":") as [string, string])).contractId( - network - ) === contractId - ); - } catch (error) { - return false; - } - } - - return false; -}; - -const getContractSpec = async ( - contractId: string, - network: NetworkNames, - logger: Logger -) => { - try { - const Sdk = getSdk(StellarSdkNext.Networks[network]); - const { xdr } = Sdk; - - const serverUrl = SOROBAN_RPC_URLS[network]; - if (!serverUrl) { - throw new Error(ERROR.UNSUPPORTED_NETWORK); - } - - const contractDataKey = getLedgerKeyContractCode(contractId, network); - const { error, result } = await getLedgerEntries( - contractDataKey, - serverUrl - ); - const entries = result.entries || []; - if (error || !entries.length) { - logger.error(error); - return { error: "Unable to fetch contract spec", result: null }; - } - - const contractCodeLedgerEntryData = entries[0].xdr; - const executable = getExecutable(contractCodeLedgerEntryData, network); - if ( - executable.switch().name === - xdr.ContractExecutableType.contractExecutableStellarAsset().name - ) { - return { - result: TOKEN_SPEC_DEFINITIONS, - error: null, - }; - } - - const wasmId = getLedgerKeyWasmId(executable, network); - const { error: wasmError, result: wasmResult } = await getLedgerEntries( - wasmId, - serverUrl - ); - const wasmEntries = wasmResult.entries || []; - if (wasmError || !wasmEntries.length) { - logger.error(wasmError); - return { error: "Unable to fetch contract spec", result: null }; - } - - const spec = await parseWasmXdr(wasmEntries[0].xdr, network); - return { result: spec, error: null }; - } catch (error) { - logger.error(error); - return { error: "Unable to fetch contract spec", result: null }; - } -}; - -export { - buildTransfer, - getContractSpec, - getIsTokenSpec, - getLedgerEntries, - getLedgerKeyContractCode, - getLedgerKeyWasmId, - getOpArgs, - getServer, - getTokenBalance, - getTokenDecimals, - getTokenName, - getTokenSymbol, - getTxBuilder, - isSacContract, - isSacContractExecutable, - isTokenSpec, - parseWasmXdr, - simulateTx, - SOROBAN_RPC_URLS, -}; +export * from "./network"; +export * from "./token"; diff --git a/src/helper/soroban-rpc/network.ts b/src/helper/soroban-rpc/network.ts new file mode 100644 index 0000000..96e1eb5 --- /dev/null +++ b/src/helper/soroban-rpc/network.ts @@ -0,0 +1,235 @@ +import * as StellarSdkNext from "stellar-sdk-next"; +import * as StellarSdk from "stellar-sdk"; +import { XdrReader } from "@stellar/js-xdr"; +import { Logger } from "pino"; + +import { NetworkNames } from "../validate"; +import { ERROR } from "../error"; +import { getSdk } from "../stellar"; +import { TOKEN_SPEC_DEFINITIONS } from "./token"; + +const SOROBAN_RPC_URLS: { [key in keyof typeof StellarSdk.Networks]?: string } = + { + PUBLIC: + "http://soroban-rpc-pubnet-prd.soroban-rpc-pubnet-prd.svc.cluster.local:8000", + TESTNET: "https://soroban-testnet.stellar.org/", + FUTURENET: "https://rpc-futurenet.stellar.org/", + }; + +const getServer = async (network: NetworkNames) => { + const serverUrl = SOROBAN_RPC_URLS[network]; + if (!serverUrl) { + throw new Error(ERROR.UNSUPPORTED_NETWORK); + } + + const Sdk = getSdk(StellarSdkNext.Networks[network]); + + return new Sdk.SorobanRpc.Server(serverUrl, { + allowHttp: serverUrl.startsWith("http://"), + }); +}; + +const getTxBuilder = async ( + pubKey: string, + network: NetworkNames, + server: StellarSdk.SorobanRpc.Server | StellarSdkNext.SorobanRpc.Server, +) => { + const Sdk = getSdk(StellarSdkNext.Networks[network]); + const sourceAccount = await server.getAccount(pubKey); + return new Sdk.TransactionBuilder(sourceAccount, { + fee: StellarSdk.BASE_FEE, + networkPassphrase: StellarSdk.Networks[network], + }); +}; + +const simulateTx = async ( + tx: StellarSdk.Transaction< + StellarSdk.Memo, + StellarSdk.Operation[] + >, + server: StellarSdk.SorobanRpc.Server | StellarSdkNext.SorobanRpc.Server, + networkPassphrase: StellarSdk.Networks, +): Promise => { + const Sdk = getSdk(networkPassphrase); + const simulatedTX = await server.simulateTransaction(tx); + if ( + Sdk.SorobanRpc.Api.isSimulationSuccess(simulatedTX) && + simulatedTX.result + ) { + return Sdk.scValToNative(simulatedTX.result.retval); + } + + if (Sdk.SorobanRpc.Api.isSimulationError(simulatedTX)) { + throw new Error(simulatedTX.error); + } + + throw new Error(ERROR.FAILED_TO_SIM); +}; + +const getLedgerKeyContractCode = ( + contractId: string, + network: NetworkNames, +) => { + const Sdk = getSdk(StellarSdkNext.Networks[network]); + const { Address, xdr } = Sdk; + + const ledgerKey = xdr.LedgerKey.contractData( + new xdr.LedgerKeyContractData({ + contract: new Address(contractId).toScAddress(), + key: xdr.ScVal.scvLedgerKeyContractInstance(), + durability: xdr.ContractDataDurability.persistent(), + }), + ); + return ledgerKey.toXDR("base64"); +}; + +const getExecutable = ( + contractLedgerEntryData: string, + network: NetworkNames, +) => { + const Sdk = getSdk(StellarSdkNext.Networks[network]); + const { xdr } = Sdk; + return xdr.LedgerEntryData.fromXDR(contractLedgerEntryData, "base64") + .contractData() + .val() + .instance() + .executable(); +}; + +const getLedgerKeyWasmId = ( + executable: + | StellarSdk.xdr.ContractExecutable + | StellarSdkNext.xdr.ContractExecutable, + network: NetworkNames, +) => { + const Sdk = getSdk(StellarSdkNext.Networks[network]); + const { xdr } = Sdk; + const contractCodeWasmHash = executable.wasmHash(); + const ledgerKey = xdr.LedgerKey.contractCode( + new xdr.LedgerKeyContractCode({ + hash: contractCodeWasmHash, + }), + ); + return ledgerKey.toXDR("base64"); +}; + +async function parseWasmXdr(xdrContents: string, network: NetworkNames) { + const Sdk = getSdk(StellarSdkNext.Networks[network]); + const { xdr, contract } = Sdk; + const wasmBuffer = xdr.LedgerEntryData.fromXDR(xdrContents, "base64") + .contractCode() + .code(); + const wasmModule = await WebAssembly.compile(wasmBuffer); + const reader = new XdrReader( + Buffer.from( + WebAssembly.Module.customSections(wasmModule, "contractspecv0")[0], + ), + ); + + const specs = []; + do { + specs.push(xdr.ScSpecEntry.read(reader)); + } while (!reader.eof); + const contractSpec = new contract.Spec(specs); + return contractSpec.jsonSchema(); +} + +const getLedgerEntries = async ( + entryKey: string, + rpcUrl: string, + id: number = new Date().getDate(), +): Promise<{ + error: Error; + result: StellarSdk.SorobanRpc.Api.RawGetLedgerEntriesResponse; +}> => { + let requestBody = { + jsonrpc: "2.0", + id: id, + method: "getLedgerEntries", + params: { + keys: [entryKey], + }, + }; + + let res = await fetch(rpcUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(requestBody), + }); + let json = await res.json(); + if (!res.ok) { + throw new Error(json); + } + return json; +}; + +const getContractSpec = async ( + contractId: string, + network: NetworkNames, + logger: Logger, +) => { + try { + const Sdk = getSdk(StellarSdkNext.Networks[network]); + const { xdr } = Sdk; + + const serverUrl = SOROBAN_RPC_URLS[network]; + if (!serverUrl) { + throw new Error(ERROR.UNSUPPORTED_NETWORK); + } + + const contractDataKey = getLedgerKeyContractCode(contractId, network); + const { error, result } = await getLedgerEntries( + contractDataKey, + serverUrl, + ); + const entries = result.entries || []; + if (error || !entries.length) { + logger.error(error); + return { error: "Unable to fetch contract spec", result: null }; + } + + const contractCodeLedgerEntryData = entries[0].xdr; + const executable = getExecutable(contractCodeLedgerEntryData, network); + if ( + executable.switch().name === + xdr.ContractExecutableType.contractExecutableStellarAsset().name + ) { + return { + result: TOKEN_SPEC_DEFINITIONS, + error: null, + }; + } + + const wasmId = getLedgerKeyWasmId(executable, network); + const { error: wasmError, result: wasmResult } = await getLedgerEntries( + wasmId, + serverUrl, + ); + const wasmEntries = wasmResult.entries || []; + if (wasmError || !wasmEntries.length) { + logger.error(wasmError); + return { error: "Unable to fetch contract spec", result: null }; + } + + const spec = await parseWasmXdr(wasmEntries[0].xdr, network); + return { result: spec, error: null }; + } catch (error) { + logger.error(error); + return { error: "Unable to fetch contract spec", result: null }; + } +}; + +export { + getContractSpec, + getExecutable, + getLedgerEntries, + getLedgerKeyContractCode, + getLedgerKeyWasmId, + getServer, + getTxBuilder, + parseWasmXdr, + simulateTx, + SOROBAN_RPC_URLS, +}; diff --git a/src/helper/soroban-rpc/token.ts b/src/helper/soroban-rpc/token.ts new file mode 100644 index 0000000..c142c01 --- /dev/null +++ b/src/helper/soroban-rpc/token.ts @@ -0,0 +1,426 @@ +import * as StellarSdkNext from "stellar-sdk-next"; +import * as StellarSdk from "stellar-sdk"; +import { NetworkNames } from "../validate"; +import { Logger } from "pino"; +import { getSdk } from "../stellar"; +import { getContractSpec, getServer, simulateTx } from "./network"; + +// https://github.com/stellar/soroban-examples/blob/main/token/src/contract.rs +enum SorobanTokenInterface { + transfer = "transfer", + mint = "mint", +} + +const TOKEN_SPEC_DEFINITIONS: { [index: string]: any } = { + allowance: { + properties: { + args: { + additionalProperties: false, + properties: { + from: { + $ref: "#/definitions/Address", + }, + spender: { + $ref: "#/definitions/Address", + }, + }, + type: "object", + required: ["from", "spender"], + }, + }, + additionalProperties: false, + }, + approve: { + properties: { + args: { + additionalProperties: false, + properties: { + from: { + $ref: "#/definitions/Address", + }, + spender: { + $ref: "#/definitions/Address", + }, + amount: { + $ref: "#/definitions/I128", + }, + expiration_ledger: { + $ref: "#/definitions/U32", + }, + }, + type: "object", + required: ["from", "spender", "amount", "expiration_ledger"], + }, + }, + additionalProperties: false, + }, + balance: { + properties: { + args: { + additionalProperties: false, + properties: { + id: { + $ref: "#/definitions/Address", + }, + }, + type: "object", + required: ["id"], + }, + }, + additionalProperties: false, + }, + transfer: { + properties: { + args: { + additionalProperties: false, + properties: { + from: { + $ref: "#/definitions/Address", + }, + to: { + $ref: "#/definitions/Address", + }, + amount: { + $ref: "#/definitions/I128", + }, + }, + type: "object", + required: ["from", "to", "amount"], + }, + }, + additionalProperties: false, + }, + transfer_from: { + properties: { + args: { + additionalProperties: false, + properties: { + spender: { + $ref: "#/definitions/Address", + }, + from: { + $ref: "#/definitions/Address", + }, + to: { + $ref: "#/definitions/Address", + }, + amount: { + $ref: "#/definitions/I128", + }, + }, + type: "object", + required: ["spender", "from", "to", "amount"], + }, + }, + additionalProperties: false, + }, + burn: { + properties: { + args: { + additionalProperties: false, + properties: { + from: { + $ref: "#/definitions/Address", + }, + amount: { + $ref: "#/definitions/I128", + }, + }, + type: "object", + required: ["from", "amount"], + }, + }, + additionalProperties: false, + }, + burn_from: { + properties: { + args: { + additionalProperties: false, + properties: { + spender: { + $ref: "#/definitions/Address", + }, + from: { + $ref: "#/definitions/Address", + }, + amount: { + $ref: "#/definitions/I128", + }, + }, + type: "object", + required: ["spender", "from", "amount"], + }, + }, + additionalProperties: false, + }, + decimals: { + properties: { + args: { + additionalProperties: false, + properties: {}, + type: "object", + }, + }, + additionalProperties: false, + }, + name: { + properties: { + args: { + additionalProperties: false, + properties: {}, + type: "object", + }, + }, + additionalProperties: false, + }, + symbol: { + properties: { + args: { + additionalProperties: false, + properties: {}, + type: "object", + }, + }, + additionalProperties: false, + }, +}; + +const getTokenDecimals = async ( + contractId: string, + server: StellarSdk.SorobanRpc.Server | StellarSdkNext.SorobanRpc.Server, + builder: StellarSdk.TransactionBuilder, + network: NetworkNames, +) => { + const Sdk = getSdk(StellarSdkNext.Networks[network]); + const contract = new Sdk.Contract(contractId); + + const tx = builder + .addOperation(contract.call("decimals")) + .setTimeout(Sdk.TimeoutInfinite) + .build(); + + const result = await simulateTx( + tx, + server, + StellarSdkNext.Networks[network], + ); + return result; +}; + +const getTokenName = async ( + contractId: string, + server: StellarSdk.SorobanRpc.Server | StellarSdkNext.SorobanRpc.Server, + builder: StellarSdk.TransactionBuilder, + network: NetworkNames, +) => { + const Sdk = getSdk(StellarSdkNext.Networks[network]); + const contract = new Sdk.Contract(contractId); + + const tx = builder + .addOperation(contract.call("name")) + .setTimeout(Sdk.TimeoutInfinite) + .build(); + + const result = await simulateTx( + tx, + server, + StellarSdkNext.Networks[network], + ); + return result; +}; + +const getTokenSymbol = async ( + contractId: string, + server: StellarSdk.SorobanRpc.Server | StellarSdkNext.SorobanRpc.Server, + builder: StellarSdk.TransactionBuilder, + network: NetworkNames, +) => { + const Sdk = getSdk(StellarSdkNext.Networks[network]); + const contract = new Sdk.Contract(contractId); + + const tx = builder + .addOperation(contract.call("symbol")) + .setTimeout(Sdk.TimeoutInfinite) + .build(); + + const result = await simulateTx( + tx, + server, + StellarSdkNext.Networks[network], + ); + return result; +}; + +const getTokenBalance = async ( + contractId: string, + params: StellarSdk.xdr.ScVal[], + server: StellarSdk.SorobanRpc.Server | StellarSdkNext.SorobanRpc.Server, + builder: StellarSdk.TransactionBuilder, + network: NetworkNames, +) => { + const Sdk = getSdk(StellarSdkNext.Networks[network]); + const contract = new Sdk.Contract(contractId); + + const tx = builder + .addOperation(contract.call("balance", ...params)) + .setTimeout(Sdk.TimeoutInfinite) + .build(); + + const result = await simulateTx( + tx, + server, + StellarSdkNext.Networks[network], + ); + return result; +}; + +const buildTransfer = ( + contractId: string, + params: StellarSdk.xdr.ScVal[], + memo: string | undefined, + builder: StellarSdk.TransactionBuilder, + networkPassphrase: StellarSdk.Networks, +) => { + const Sdk = getSdk(networkPassphrase); + const contract = new Sdk.Contract(contractId); + + const tx = builder + .addOperation(contract.call("transfer", ...params)) + .setTimeout(Sdk.TimeoutInfinite); + + if (memo) { + tx.addMemo(Sdk.Memo.text(memo)); + } + + return tx.build(); +}; + +const getIsTokenSpec = async ( + contractId: string, + network: NetworkNames, + logger: Logger, +) => { + try { + const spec = await getContractSpec(contractId, network, logger); + if (spec.error) { + throw new Error(spec.error); + } + const res = isTokenSpec(spec.result!); + return res; + } catch (error) { + return false; + } +}; + +const isTokenSpec = (spec: Record) => { + for (const tokenMethod of Object.keys(TOKEN_SPEC_DEFINITIONS)) { + const specMethod = spec.definitions[tokenMethod]; + if ( + !specMethod || + JSON.stringify(specMethod) !== + JSON.stringify(TOKEN_SPEC_DEFINITIONS[tokenMethod]) + ) { + return false; + } + } + return true; +}; + +const isSacContractExecutable = async ( + contractId: string, + network: NetworkNames, +) => { + // verify the contract executable in the instance entry + // The SAC has a unique contract executable type + const Sdk = getSdk(StellarSdkNext.Networks[network]); + const { xdr } = Sdk; + const server = await getServer(network); + const instance = new Sdk.Contract(contractId).getFootprint(); + const ledgerKeyContractCode = instance.toXDR("base64"); + + const { entries } = await server.getLedgerEntries( + xdr.LedgerKey.fromXDR(ledgerKeyContractCode, "base64"), + ); + + if (entries && entries.length) { + const parsed = entries[0].val; + const executable = parsed.contractData().val().instance().executable(); + + return ( + executable.switch().name === + xdr.ContractExecutableType.contractExecutableStellarAsset().name + ); + } + + return false; +}; + +const isSacContract = ( + name: string, + contractId: string, + network: StellarSdk.Networks, +) => { + const Sdk = getSdk(network); + if (name.includes(":")) { + try { + return ( + new Sdk.Asset(...(name.split(":") as [string, string])).contractId( + network, + ) === contractId + ); + } catch (error) { + return false; + } + } + + return false; +}; + +const getOpArgs = ( + fnName: string, + args: StellarSdk.xdr.ScVal[], + network: NetworkNames, +) => { + const Sdk = getSdk(StellarSdk.Networks[network]); + + let amount: number; + let from; + let to; + + switch (fnName) { + case SorobanTokenInterface.transfer: + from = Sdk.StrKey.encodeEd25519PublicKey( + args[0].address().accountId().ed25519(), + ); + to = Sdk.StrKey.encodeEd25519PublicKey( + args[1].address().accountId().ed25519(), + ); + amount = Sdk.scValToNative(args[2]).toString(); + break; + case SorobanTokenInterface.mint: + to = Sdk.StrKey.encodeEd25519PublicKey( + args[0].address().accountId().ed25519(), + ); + amount = Sdk.scValToNative(args[1]).toString(); + break; + default: + amount = 0; + } + + return { from, to, amount }; +}; + +export { + buildTransfer, + getIsTokenSpec, + getOpArgs, + getTokenBalance, + getTokenDecimals, + getTokenName, + getTokenSymbol, + isSacContract, + isSacContractExecutable, + isTokenSpec, + SorobanTokenInterface, + TOKEN_SPEC_DEFINITIONS, +}; diff --git a/src/route/index.ts b/src/route/index.ts index d9ad950..2d458a4 100644 --- a/src/route/index.ts +++ b/src/route/index.ts @@ -462,17 +462,9 @@ export async function initApiServer( } try { - const { result, error } = await getIsTokenSpec( - contractId, - network, - logger, - ); + const isToken = await getIsTokenSpec(contractId, network, logger); - if (error) { - reply.code(400).send({ error, result: null }); - } else { - reply.code(200).send({ data: result, error: null }); - } + reply.code(200).send({ data: isToken, error: null }); } catch (error) { reply.code(500).send("Unexpected Server Error"); } @@ -621,12 +613,10 @@ export async function initApiServer( return reply.code(500).send(ERROR.SERVER_ERROR); } } - return reply - .code(200) - .send({ - data: { status: "miss" }, - error: ERROR.SCAN_SITE_DISABLED, - }); + return reply.code(200).send({ + data: { status: "miss" }, + error: ERROR.SCAN_SITE_DISABLED, + }); }, }); diff --git a/src/service/mercury/index.test.ts b/src/service/mercury/index.test.ts index 7fd550a..b979a14 100644 --- a/src/service/mercury/index.test.ts +++ b/src/service/mercury/index.test.ts @@ -11,7 +11,7 @@ import { import { transformAccountBalancesCurrentData } from "./helpers/transformers"; import { ERROR_MESSAGES } from "."; import { ERROR } from "../../helper/error"; -import * as SorobanRpcHelper from "../../helper/soroban-rpc"; +import * as SorobanRpcHelper from "../../helper/soroban-rpc/token"; describe("Mercury Service", () => { afterEach(() => { @@ -22,7 +22,7 @@ describe("Mercury Service", () => { const { data } = await mockMercuryClient.getAccountHistory( pubKey, "TESTNET", - true + true, ); const payment = (data || []).find((d) => { if ("asset_code" in d && d.asset_code === "DT") { @@ -36,7 +36,7 @@ describe("Mercury Service", () => { it("can build a balance ledger key for a pub key", async () => { const ledgerKey = mockMercuryClient.tokenBalanceKey(pubKey, "TESTNET"); const scVal = xdr.ScVal.fromXDR( - Buffer.from(ledgerKey, "base64") + Buffer.from(ledgerKey, "base64"), ).value() as xdr.ScVal[]; const [scValBalance, scValAddress] = scVal; @@ -54,7 +54,7 @@ describe("Mercury Service", () => { pubKey, contracts, "TESTNET", - true + true, ); const tokenDetails = { CCWAMYJME4H5CKG7OLXGC2T4M6FL52XCZ3OQOAV6LL3GLA4RO4WH3ASP: { @@ -80,7 +80,7 @@ describe("Mercury Service", () => { "CCWAMYJME4H5CKG7OLXGC2T4M6FL52XCZ3OQOAV6LL3GLA4RO4WH3ASP", "CBGTG7XFRY3L6OKAUTR6KGDKUXUQBX3YDJ3QFDYTGVMOM7VV4O7NCODG", ], - Networks.TESTNET + Networks.TESTNET, ); const expected = { ...transformedData, @@ -100,7 +100,7 @@ describe("Mercury Service", () => { }; expect(response).toEqual(expected); expect(mockMercuryClient.tokens["TESTNET"]).toEqual( - queryMockResponse[mutation.authenticate].authenticate?.jwtToken + queryMockResponse[mutation.authenticate].authenticate?.jwtToken, ); }); @@ -144,7 +144,7 @@ describe("Mercury Service", () => { mockRetryable.mockRejectedValue(new Error(err)); await expect( - mockMercuryClient.renewAndRetry(mockRetryable, "TESTNET") + mockMercuryClient.renewAndRetry(mockRetryable, "TESTNET"), ).rejects.toThrowError(err); expect(mockRetryable).toHaveBeenCalledTimes(1); expect(spyRenewToken).not.toHaveBeenCalled(); @@ -177,7 +177,7 @@ describe("Mercury Service", () => { ..._args: Parameters ): ReturnType => { return Promise.resolve([{ publickey: "nope" }]); - } + }, ); jest @@ -187,12 +187,12 @@ describe("Mercury Service", () => { ..._args: Parameters ): ReturnType => { return Promise.resolve({ data: {}, error: null }); - } + }, ); const response = await mockMercuryClient.getAccountHistoryMercury( pubKey, - "TESTNET" + "TESTNET", ); expect(response).toHaveProperty("error"); expect(response.error).toBeInstanceOf(Error); @@ -219,7 +219,7 @@ describe("Mercury Service", () => { name: "wBTC:GATALTGTWIOT6BUDBCZM3Q4OQ4BO2COLOAZ7IYSKPLC2PMSOPPGF5V56", symbol: "wBTC", decimals: "5", - }) + }), ); //second contract jest.spyOn(mockMercuryClient, "tokenDetails").mockReturnValueOnce( @@ -227,13 +227,13 @@ describe("Mercury Service", () => { name: "baz", symbol: "BAZ", decimals: "5", - }) + }), ); const data = await mockMercuryClient.getTokenBalancesSorobanRPC( pubKey, contracts, - "TESTNET" + "TESTNET", ); const expected = { @@ -305,7 +305,7 @@ describe("Mercury Service", () => { sponsoringCount: 0, sponsor: "", }); - } + }, ); jest .spyOn(mockMercuryClient, "getTokenBalancesSorobanRPC") @@ -335,14 +335,14 @@ describe("Mercury Service", () => { }, }, }); - } + }, ); const data = await mockMercuryClient.getAccountBalances( pubKey, contracts, "TESTNET", - false + false, ); const expected = { @@ -433,10 +433,10 @@ describe("Mercury Service", () => { rawResponse as any, tokenDetails, contracts, - Networks.TESTNET + Networks.TESTNET, ); const wBtcBalances = Object.keys(transformedResponse.balances).filter( - (key) => key.includes("wBTC") + (key) => key.includes("wBTC"), ); expect(wBtcBalances).toHaveLength(1); });