Skip to content

Commit

Permalink
[Build Transaction] Added operations (#843)
Browse files Browse the repository at this point in the history
* Operation type description + docs URL

* Payment operation

* Manage buy and sell operations

* Create Passive Sell Offer operation
quietbits authored May 9, 2024
1 parent 1ce5100 commit 1f4b89e
Showing 14 changed files with 709 additions and 158 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -25,6 +25,7 @@
"@tanstack/react-query": "^5.32.1",
"@tanstack/react-query-devtools": "^5.32.1",
"@typescript-eslint/eslint-plugin": "^7.8.0",
"bignumber.js": "^9.1.2",
"bindings-js": "file:./src/temp/stellar-xdr-web",
"dompurify": "^3.1.2",
"html-react-parser": "^5.1.10",
9 changes: 6 additions & 3 deletions src/app/(sidebar)/endpoints/[[...pages]]/page.tsx
Original file line number Diff line number Diff line change
@@ -18,7 +18,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { InfoCards } from "@/components/InfoCards";
import { SdsLink } from "@/components/SdsLink";
import { NextLink } from "@/components/NextLink";
import { formComponentTemplate } from "@/components/formComponentTemplate";
import { formComponentTemplateEndpoints } from "@/components/formComponentTemplateEndpoints";
import { PrettyJson } from "@/components/PrettyJson";
import { InputSideElement } from "@/components/InputSideElement";

@@ -232,7 +232,7 @@ export default function Endpoints() {
// Validate saved params when the page loads
const paramErrors = () => {
return Object.keys(params).reduce((res, param) => {
const error = formComponentTemplate(param)?.validate?.(
const error = formComponentTemplateEndpoints(param)?.validate?.(
parseJsonString(params[param]),
requiredFields.includes(param),
);
@@ -498,7 +498,10 @@ export default function Endpoints() {
{renderPostPayload()}

{allFields.map((f) => {
const component = formComponentTemplate(f, pageData.custom?.[f]);
const component = formComponentTemplateEndpoints(
f,
pageData.custom?.[f],
);

if (component) {
const isRequired = requiredFields.includes(f);
389 changes: 251 additions & 138 deletions src/app/(sidebar)/transaction/build/components/Operations.tsx

Large diffs are not rendered by default.

36 changes: 23 additions & 13 deletions src/app/(sidebar)/transaction/build/components/TransactionXdr.tsx
Original file line number Diff line number Diff line change
@@ -11,6 +11,8 @@ import { ValidationResponseCard } from "@/components/ValidationResponseCard";
import { Box } from "@/components/layout/Box";

import { isEmptyObject } from "@/helpers/isEmptyObject";
import { xdrUtils } from "@/helpers/xdr/utils";

import { useStore } from "@/store/useStore";
import { Routes } from "@/constants/routes";
import { AnyObject, KeysOfUnion } from "@/types/types";
@@ -55,7 +57,7 @@ export const TransactionXdr = () => {
val = BigInt(value);
break;
case "fee":
val = BigInt(value);
val = BigInt(value) * BigInt(txnOperations.length);
break;
case "cond":
// eslint-disable-next-line no-case-declarations
@@ -91,18 +93,27 @@ export const TransactionXdr = () => {
return { ...res, [key]: val };
}, {});

const parseOpParams = ({
params,
amountParams,
}: {
params: AnyObject;
amountParams: string[];
}) => {
const getXdrVal = (key: string, val: any) => {
switch (key) {
// Amount
case "amount":
case "buy_amount":
case "starting_balance":
return xdrUtils.toAmount(val);
// Number
case "offer_id":
return BigInt(val);
// Price
case "price":
return xdrUtils.toPrice(val);
default:
return val;
}
};

const parseOpParams = ({ params }: { params: AnyObject }) => {
return Object.entries(params).reduce((res, [key, val]) => {
res[key] = amountParams.includes(key)
? // Multiplying to create raw XDR amount
BigInt(val) * BigInt(10000000)
: val;
res[key] = getXdrVal(key, val);

return res;
}, {} as AnyObject);
@@ -113,7 +124,6 @@ export const TransactionXdr = () => {
body: {
[op.operation_type]: parseOpParams({
params: op.params,
amountParams: ["starting_balance"],
}),
},
}));
Original file line number Diff line number Diff line change
@@ -48,15 +48,15 @@ type TemplateRenderIncludeFailedProps = {
isRequired?: boolean;
};

type FormComponentTemplate = {
type FormComponentTemplateEndpointsProps = {
render: (...args: any[]) => JSX.Element;
validate: ((...args: any[]) => any) | null;
};

export const formComponentTemplate = (
export const formComponentTemplateEndpoints = (
id: string,
custom?: AnyObject,
): FormComponentTemplate | null => {
): FormComponentTemplateEndpointsProps | null => {
switch (id) {
case "account_id":
return {
216 changes: 216 additions & 0 deletions src/components/formComponentTemplateTxnOps.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
import { JSX } from "react";

import { PubKeyPicker } from "@/components/FormElements/PubKeyPicker";
import { TextPicker } from "@/components/FormElements/TextPicker";
import { AssetPicker } from "@/components/FormElements/AssetPicker";
import { PositiveIntPicker } from "@/components/FormElements/PositiveIntPicker";

import { validate } from "@/validate";
import { AnyObject, AssetObjectValue, JsonAsset } from "@/types/types";

// Types
type TemplateRenderProps = {
value: string | undefined;
error: string | undefined;
onChange: (val: any) => void;
isRequired?: boolean;
};

type TemplateRenderAssetProps = {
value: JsonAsset | undefined;
error: { code: string | undefined; issuer: string | undefined } | undefined;
onChange: (asset: AssetObjectValue | undefined) => void;
isRequired?: boolean;
};

type FormComponentTemplateTxnOpsProps = {
render: (...args: any[]) => JSX.Element;
validate: ((...args: any[]) => any) | null;
};

const assetPickerValue = (
value: JsonAsset | undefined,
): AssetObjectValue | undefined => {
if (!value) {
return undefined;
}

if (value === "native") {
return { type: "native", code: "", issuer: "" };
}

const type = Object.keys(value)[0] as keyof typeof value;
const val = value[type] as {
asset_code: string;
issuer: string;
};

return {
type,
code: val.asset_code,
issuer: val.issuer,
};
};

export const formComponentTemplateTxnOps = ({
param,
opType,
index,
custom,
}: {
param: string;
opType: string;
index: number;
custom?: AnyObject;
}): FormComponentTemplateTxnOpsProps | null => {
const id = `${index}-${opType}-${param}`;

switch (param) {
case "amount":
case "buy_amount":
return {
render: (templ: TemplateRenderProps) => (
<TextPicker
key={id}
id={id}
label={custom?.label || "Amount"}
labelSuffix={!templ.isRequired ? "optional" : undefined}
value={templ.value || ""}
error={templ.error}
onChange={templ.onChange}
note={custom?.note}
/>
),
validate: validate.amount,
};
case "asset":
return {
render: (templ: TemplateRenderAssetProps) => (
<AssetPicker
key={id}
assetInput="alphanumeric"
id={id}
label="Asset"
labelSuffix={!templ.isRequired ? "optional" : undefined}
value={assetPickerValue(templ.value)}
error={templ.error}
includeNative
onChange={templ.onChange}
/>
),
validate: validate.assetJson,
};
case "buying":
return {
render: (templ: TemplateRenderAssetProps) => (
<AssetPicker
key={id}
assetInput="alphanumeric"
id={id}
label="Buying"
labelSuffix={!templ.isRequired ? "optional" : undefined}
value={assetPickerValue(templ.value)}
error={templ.error}
includeNative
onChange={templ.onChange}
/>
),
validate: validate.assetJson,
};
case "destination":
return {
render: (templ: TemplateRenderProps) => (
<PubKeyPicker
key={id}
id={id}
label="Destination"
labelSuffix={!templ.isRequired ? "optional" : undefined}
value={templ.value || ""}
error={templ.error}
onChange={templ.onChange}
/>
),
validate: validate.publicKey,
};
case "offer_id":
return {
render: (templ: TemplateRenderProps) => (
<PositiveIntPicker
key={id}
id={id}
label={custom?.label || "Offer ID"}
labelSuffix={!templ.isRequired ? "optional" : undefined}
value={templ.value || ""}
error={templ.error}
onChange={templ.onChange}
note={custom?.note}
/>
),
validate: validate.positiveInt,
};
case "price":
return {
render: (templ: TemplateRenderProps) => (
<TextPicker
key={id}
id={id}
label={custom?.label || "Price"}
labelSuffix={!templ.isRequired ? "optional" : undefined}
value={templ.value || ""}
error={templ.error}
onChange={templ.onChange}
/>
),
validate: validate.positiveNumber,
};
case "selling":
return {
render: (templ: TemplateRenderAssetProps) => (
<AssetPicker
key={id}
assetInput="alphanumeric"
id={id}
label="Selling"
labelSuffix={!templ.isRequired ? "optional" : undefined}
value={assetPickerValue(templ.value)}
error={templ.error}
includeNative
onChange={templ.onChange}
/>
),
validate: validate.assetJson,
};
case "source_account":
return {
render: (templ: TemplateRenderProps) => (
<PubKeyPicker
key={id}
id={id}
label="Source Account"
labelSuffix={!templ.isRequired ? "optional" : undefined}
value={templ.value || ""}
error={templ.error}
onChange={templ.onChange}
/>
),
validate: validate.publicKey,
};
case "starting_balance":
return {
render: (templ: TemplateRenderProps) => (
<TextPicker
key={id}
id={id}
label="Starting Balance"
labelSuffix={!templ.isRequired ? "optional" : undefined}
value={templ.value || ""}
error={templ.error}
onChange={templ.onChange}
/>
),
validate: validate.amount,
};
default:
return null;
}
};
70 changes: 70 additions & 0 deletions src/constants/transactionOperations.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { AnyObject } from "@/types/types";

type TransactionOperation = {
label: string;
description: string;
docsUrl: string;
params: string[];
requiredParams: string[];
custom?: AnyObject;
};

export const TRANSACTION_OPERATIONS: { [key: string]: TransactionOperation } = {
@@ -16,4 +19,71 @@ export const TRANSACTION_OPERATIONS: { [key: string]: TransactionOperation } = {
params: ["destination", "starting_balance"],
requiredParams: ["destination", "starting_balance"],
},
payment: {
label: "Payment",
description:
"Sends an amount in a specific asset to a destination account.",
docsUrl:
"https://developers.stellar.org/docs/learn/fundamentals/list-of-operations#payment",
params: ["destination", "asset", "amount"],
requiredParams: ["destination", "asset", "amount"],
},
manage_sell_offer: {
label: "Manage Sell Offer",
description: "Creates, updates, or deletes an offer.",
docsUrl:
"https://developers.stellar.org/docs/learn/fundamentals/list-of-operations#manage-sell-offer",
params: ["selling", "buying", "amount", "price", "offer_id"],
requiredParams: ["selling", "buying", "amount", "price", "offer_id"],
custom: {
amount: {
label: "Amount you are selling",
note: "An amount of zero will delete the offer.",
},
price: {
label: "Price of 1 unit of selling in terms of buying",
},
offer_id: {
note: "If 0, will create a new offer. Existing offer id numbers can be found using the Offers for Account endpoint.",
},
},
},
manage_buy_offer: {
label: "Manage Buy Offer",
description: "Creates, updates, or deletes an offer.",
docsUrl:
"https://developers.stellar.org/docs/learn/fundamentals/list-of-operations#manage-buy-offer",
params: ["selling", "buying", "buy_amount", "price", "offer_id"],
requiredParams: ["selling", "buying", "buy_amount", "price", "offer_id"],
custom: {
buy_amount: {
label: "Amount you are buying",
note: "An amount of zero will delete the offer.",
},
price: {
label: "Price of 1 unit of buying in terms of selling",
},
offer_id: {
note: "If 0, will create a new offer. Existing offer id numbers can be found using the Offers for Account endpoint.",
},
},
},
create_passive_sell_offer: {
label: "Create Passive Sell Offer",
description:
"Creates an offer that does not take another offer of equal price when created.",
docsUrl:
"https://developers.stellar.org/docs/learn/fundamentals/list-of-operations#create-passive-sell-offer",
params: ["selling", "buying", "amount", "price"],
requiredParams: ["selling", "buying", "amount", "price"],
custom: {
amount: {
label: "Amount you are selling",
note: "An amount of zero will delete the offer.",
},
price: {
label: "Price of 1 unit of selling in terms of buying",
},
},
},
};
42 changes: 42 additions & 0 deletions src/helpers/xdr/fraction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// This content is copied from js-stellar-base (src/util/continued_fraction.js)
import BigNumber from "bignumber.js";

const MAX_INT = ((1 << 31) >>> 0) - 1;

export function best_r(rawNumber: string | number | BigNumber) {
let number = new BigNumber(rawNumber);
let a;
let f;
const fractions = [
[new BigNumber(0), new BigNumber(1)],
[new BigNumber(1), new BigNumber(0)],
];
let i = 2;

// eslint-disable-next-line no-constant-condition
while (true) {
if (number.gt(MAX_INT)) {
break;
}
a = number.integerValue(BigNumber.ROUND_FLOOR);
f = number.minus(a);
const h = a.times(fractions[i - 1][0]).plus(fractions[i - 2][0]);
const k = a.times(fractions[i - 1][1]).plus(fractions[i - 2][1]);
if (h.gt(MAX_INT) || k.gt(MAX_INT)) {
break;
}
fractions.push([h, k]);
if (f.eq(0)) {
break;
}
number = new BigNumber(1).div(f);
i += 1;
}
const [n, d] = fractions[fractions.length - 1];

if (n.isZero() || d.isZero()) {
throw new Error("Couldn't find approximation");
}

return [n.toNumber(), d.toNumber()];
}
36 changes: 36 additions & 0 deletions src/helpers/xdr/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// XDR helpers from js-stellar-base
import { xdr } from "@stellar/stellar-sdk";
import BigNumber from "bignumber.js";
import { best_r } from "./fraction";

const ONE = 10000000;

function toXDRAmount(value: string) {
// Using BigNumber to handle decimal point values
return BigInt(new BigNumber(value).times(ONE).toString());
}

function fromXDRAmount(value: string) {
return new BigNumber(value).div(ONE).toFixed(7);
}

function toXDRPrice(price: string) {
const approx = best_r(price);

return {
n: parseInt(approx[0].toString(), 10),
d: parseInt(approx[1].toString(), 10),
};
}

function fromXDRPrice(price: xdr.Price) {
const n = new BigNumber(price.n());
return n.div(new BigNumber(price.d())).toString();
}

export const xdrUtils = {
toAmount: toXDRAmount,
fromAmount: fromXDRAmount,
toPrice: toXDRPrice,
fromPrice: fromXDRPrice,
};
15 changes: 15 additions & 0 deletions src/types/types.ts
Original file line number Diff line number Diff line change
@@ -88,6 +88,21 @@ export type AssetObject = {
value: AssetObjectValue;
};

export type JsonAsset =
| "native"
| {
credit_alphanum4: {
asset_code: string;
issuer: string;
};
}
| {
credit_alphanum12: {
asset_code: string;
issuer: string;
};
};

// =============================================================================
// Transaction
// =============================================================================
4 changes: 4 additions & 0 deletions src/validate/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { amount } from "./methods/amount";
import { asset } from "./methods/asset";
import { assetCode } from "./methods/assetCode";
import { assetJson } from "./methods/assetJson";
import { assetMulti } from "./methods/assetMulti";
import { memo } from "./methods/memo";
import { positiveInt } from "./methods/positiveInt";
import { positiveNumber } from "./methods/positiveNumber";
import { publicKey } from "./methods/publicKey";
import { timeBounds } from "./methods/timeBounds";
import { transactionHash } from "./methods/transactionHash";
@@ -13,9 +15,11 @@ export const validate = {
amount,
asset,
assetCode,
assetJson,
assetMulti,
memo,
positiveInt,
positiveNumber,
publicKey,
timeBounds,
transactionHash,
2 changes: 1 addition & 1 deletion src/validate/methods/asset.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { isEmptyObject } from "@/helpers/isEmptyObject";
import { AssetObjectValue } from "@/types/types";
import { assetCode } from "./assetCode";
import { publicKey } from "./publicKey";
import { AssetObjectValue } from "@/types/types";

export const asset = (asset: AssetObjectValue | undefined) => {
if (asset?.type && asset.type === "native") {
32 changes: 32 additions & 0 deletions src/validate/methods/assetJson.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { isEmptyObject } from "@/helpers/isEmptyObject";
import { JsonAsset } from "@/types/types";
import { assetCode } from "./assetCode";
import { publicKey } from "./publicKey";

// Validate asset in XDR or JSON format
export const assetJson = (asset: JsonAsset | undefined) => {
if (!asset || asset === "native") {
return false;
}

const type = Object.keys(asset)[0] as keyof typeof asset;
const values = asset[type] as {
asset_code: string;
issuer: string;
};

const invalid = Object.entries({
code: assetCode(values.asset_code || "", type),
issuer: publicKey(values.issuer || ""),
}).reduce((res, cur) => {
const [key, value] = cur;

if (value) {
return { ...res, [key]: value };
}

return res;
}, {});

return isEmptyObject(invalid) ? false : invalid;
};
9 changes: 9 additions & 0 deletions src/validate/methods/positiveNumber.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export const positiveNumber = (value: string) => {
if (value.toString().charAt(0) === "-") {
return "Expected a positive number or zero.";
} else if (!value.toString().match(/^[0-9]*(\.[0-9]+){0,1}$/g)) {
return "Expected a positive number with a period for the decimal point.";
}

return false;
};

0 comments on commit 1f4b89e

Please sign in to comment.