Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,16 @@ import { useStore } from "@/store/useStore";

import { Box } from "@/components/layout/Box";

import { getNetworkHeaders } from "@/helpers/getNetworkHeaders";
import { useGetTokenInfoFromRpc } from "@/query/useGetTokenInfoFromRpc";

import { InvokeContractForm, SigningMethod } from "./InvokeContractForm";

// SEP-41 core functions used to decide whether a contract is token-shaped
// before spending a simulation on `decimals()`. Requiring all three avoids
// firing on non-token contracts that coincidentally expose a `decimals`.
const TOKEN_SHAPE_FUNCS = ["decimals", "transfer", "balance"];

export const InvokeContract = ({
isLoading,
contractId,
Expand All @@ -20,9 +28,24 @@ export const InvokeContract = ({
contractSpec: contract.Spec;
contractClientError: Error | null | undefined;
}) => {
const { walletKit } = useStore();
const { network, walletKit } = useStore();
const [signingMethod, setSigningMethod] = useState<SigningMethod>("wallet");

const funcNames = new Set(
contractSpec?.funcs()?.map((func) => func.name().toString()) || [],
);
const isTokenShaped = TOKEN_SHAPE_FUNCS.every((fn) => funcNames.has(fn));

// Resolve decimals/symbol once per contract. When this returns null (not a
// token, or simulation failed), amount fields render as plain integers.
const { data: tokenInfo } = useGetTokenInfoFromRpc({
contractId,
networkPassphrase: network.passphrase,
rpcUrl: network.rpcUrl,
headers: getNetworkHeaders(network, "rpc"),
enabled: isTokenShaped && Boolean(network.rpcUrl),
});

const renderFunctionCard = () => {
const invokeContractSpecFuncs = contractSpec?.funcs();

Expand All @@ -38,6 +61,7 @@ export const InvokeContract = ({
contractId={contractId}
funcName={funcName}
signingMethod={signingMethod}
tokenInfo={tokenInfo || undefined}
/>
);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,12 @@ import { dereferenceSchema } from "@/helpers/dereferenceSchema";
import { getNetworkHeaders } from "@/helpers/getNetworkHeaders";
import { getTxnToSimulate } from "@/helpers/sorobanUtils";

import { SorobanInvokeValue, XdrFormatType, AnyObject } from "@/types/types";
import {
SorobanInvokeValue,
XdrFormatType,
AnyObject,
TokenInfo,
} from "@/types/types";

import { trackEvent, TrackingEvent } from "@/metrics/tracking";

Expand All @@ -71,11 +76,13 @@ export const InvokeContractForm = ({
funcName,
contractSpec,
signingMethod = "wallet",
tokenInfo,
}: {
contractId: string;
funcName: string;
contractSpec: contract.Spec;
signingMethod?: SigningMethod;
tokenInfo?: TokenInfo;
}) => {
const { network, walletKit } = useStore();
const { resetAll, setBuildParams, setBuildSorobanOperation } =
Expand Down Expand Up @@ -626,6 +633,7 @@ export const InvokeContractForm = ({
schema={dereferencedSchema as JSONSchema7}
onChange={handleChange}
parsedSorobanOperation={formValue}
tokenInfo={tokenInfo}
/>
)}
</Box>
Expand Down
6 changes: 6 additions & 0 deletions src/components/SmartContractJsonSchema/JsonSchemaRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export const JsonSchemaRenderer = ({
parsedSorobanOperation,
formError,
setFormError,
tokenInfo,
}: JsonSchemaFormProps) => {
const schemaType = jsonSchema.getSchemaType(schema);

Expand Down Expand Up @@ -64,6 +65,7 @@ export const JsonSchemaRenderer = ({
parsedSorobanOperation={parsedSorobanOperation}
formError={formError}
setFormError={setFormError}
tokenInfo={tokenInfo}
/>
</Card>
</Box>
Expand All @@ -81,6 +83,7 @@ export const JsonSchemaRenderer = ({
parsedSorobanOperation={parsedSorobanOperation}
formError={formError}
setFormError={setFormError}
tokenInfo={tokenInfo}
/>
);
},
Expand All @@ -98,6 +101,7 @@ export const JsonSchemaRenderer = ({
renderer: JsonSchemaRenderer,
formError,
setFormError,
tokenInfo,
});
}

Expand All @@ -111,6 +115,7 @@ export const JsonSchemaRenderer = ({
renderer: JsonSchemaRenderer,
formError,
setFormError,
tokenInfo,
});
}

Expand All @@ -123,5 +128,6 @@ export const JsonSchemaRenderer = ({
onChange,
formError,
setFormError,
tokenInfo,
});
};
159 changes: 159 additions & 0 deletions src/components/SmartContractJsonSchema/TokenAmountInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import React, { useState } from "react";
import { Input, RadioButton } from "@stellar/design-system";

import {
baseUnitsToTokenAmount,
tokenAmountToBaseUnits,
} from "@/helpers/tokenAmount";
import { validate } from "@/validate";

import { Box } from "@/components/layout/Box";

/**
* Decimals-aware amount input for SEP-41 token amount args (i128/u128).
*
* Two modes, toggled per field:
* - `tokens` — the user types a human-readable amount (e.g. `5.5`); the
* component scales it by the token's decimals and stores the raw base-unit
* integer. The exact value that will be submitted is always shown below.
* - `raw` — identical to a plain integer field, with a live "= 5 JAAA" note.
*
* The canonical stored value (via `onRawChange`) is *always* the raw base-unit
* integer string, so simulation/submission paths are untouched. The
* human-units string is local component state only.
*/
export const TokenAmountInput = ({
id,
label,
value,
decimals,
symbol,
isSigned,
error,
onRawChange,
onError,
}: {
id: string;
label: React.ReactNode;
/** Canonical raw base-unit integer string held in the form store. */
value: string;
decimals: number;
symbol?: string;
/** `true` for i128 (negatives allowed), `false` for u128. */
isSigned: boolean;
error?: string;
onRawChange: (raw: string) => void;
onError: (error: string | false) => void;
}) => {
const tokenLabel = symbol || "tokens";

const safeBaseToToken = (raw: string) => {
try {
return raw ? baseUnitsToTokenAmount(raw, decimals) : "";
} catch {
return "";
}
};

const [mode, setMode] = useState<"tokens" | "raw">("tokens");
const [tokensDisplay, setTokensDisplay] = useState<string>(() =>
safeBaseToToken(value),
);

const handleTokensChange = (next: string) => {
setTokensDisplay(next);

const err = validate.getTokenAmountError({
value: next,
decimals,
isSigned,
});
onError(err);

if (!err && next) {
onRawChange(tokenAmountToBaseUnits(next, decimals));
} else {
// Clear the canonical value so simulate/submit stays disabled until the
// token-units entry is valid again.
onRawChange("");
}
};

const handleRawChange = (next: string) => {
const err = isSigned
? validate.getI128Error(next)
: validate.getU128Error(next);
onError(err);
onRawChange(next);
};

const switchMode = (nextMode: "tokens" | "raw") => {
if (nextMode === mode) {
return;
}

if (nextMode === "tokens") {
setTokensDisplay(safeBaseToToken(value));
}

// Clear stale errors from the previous mode; the counterpart value is a
// valid representation of the same stored raw integer.
onError(false);
Comment on lines +99 to +101
setMode(nextMode);
};

const tokensNote = value
? `Will submit: ${value} (${decimals} ${
decimals === 1 ? "decimal" : "decimals"
})`
: `Enter the amount in ${tokenLabel}; it’s scaled by ${decimals} ${
decimals === 1 ? "decimal" : "decimals"
}.`;

const rawTokenEquivalent = safeBaseToToken(value);
const rawNote = rawTokenEquivalent
? `= ${rawTokenEquivalent} ${tokenLabel} (${decimals} ${
decimals === 1 ? "decimal" : "decimals"
})`
: undefined;

return (
<Box gap="sm">
<Box gap="md" direction="row" align="center" justify="end" wrap="wrap">
<RadioButton
id={`${id}-mode-tokens`}
name={`${id}-amount-mode`}
label={`Token units${symbol ? ` (${symbol})` : ""}`}
fieldSize="sm"
value="tokens"
checked={mode === "tokens"}
onChange={() => switchMode("tokens")}
/>
<RadioButton
id={`${id}-mode-raw`}
name={`${id}-amount-mode`}
label="Raw (base units)"
fieldSize="sm"
value="raw"
checked={mode === "raw"}
onChange={() => switchMode("raw")}
/>
</Box>

<Input
id={id}
label={label}
fieldSize="md"
value={mode === "tokens" ? tokensDisplay : value}
error={error}
onChange={(e) =>
mode === "tokens"
? handleTokensChange(e.target.value)
: handleRawChange(e.target.value)
}
rightElement={mode === "tokens" ? tokenLabel : undefined}
note={mode === "tokens" ? tokensNote : rawNote}
/>
</Box>
);
};
6 changes: 6 additions & 0 deletions src/components/SmartContractJsonSchema/renderArrayType.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type {
AnyObject,
JsonSchemaFormProps,
SorobanInvokeValue,
TokenInfo,
} from "@/types/types";

export const renderArrayType = ({
Expand All @@ -22,6 +23,7 @@ export const renderArrayType = ({
onChange,
formError,
setFormError,
tokenInfo,
}: {
schema: JSONSchema7;
path: string[];
Expand All @@ -30,6 +32,7 @@ export const renderArrayType = ({
onChange: (value: SorobanInvokeValue) => void;
formError: AnyObject;
setFormError: (error: AnyObject) => void;
tokenInfo?: TokenInfo;
}) => {
const name = path.join(".");
const invokeContractBaseProps = {
Expand Down Expand Up @@ -58,6 +61,7 @@ export const renderArrayType = ({
onChange,
formError,
setFormError,
tokenInfo,
});
});
}
Expand Down Expand Up @@ -102,6 +106,7 @@ export const renderArrayType = ({
onChange,
formError,
setFormError,
tokenInfo,
});
})}
</>
Expand All @@ -116,6 +121,7 @@ export const renderArrayType = ({
onChange,
formError,
setFormError,
tokenInfo,
})}
</Box>
)}
Expand Down
4 changes: 4 additions & 0 deletions src/components/SmartContractJsonSchema/renderOneOf.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
AnyObject,
JsonSchemaFormProps,
SorobanInvokeValue,
TokenInfo,
} from "@/types/types";
import { renderTupleType } from "./renderTupleType";

Expand All @@ -24,6 +25,7 @@ export const renderOneOf = ({
onChange,
formError,
setFormError,
tokenInfo,
}: {
name: string;
schema: JSONSchema7;
Expand All @@ -33,6 +35,7 @@ export const renderOneOf = ({
onChange: (value: SorobanInvokeValue) => void;
formError: AnyObject;
setFormError: (error: AnyObject) => void;
tokenInfo?: TokenInfo;
}) => {
if (!schema?.oneOf) {
return null;
Expand Down Expand Up @@ -162,6 +165,7 @@ export const renderOneOf = ({
renderer,
formError,
setFormError,
tokenInfo,
})
: null}
</Box>
Expand Down
Loading
Loading