diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..86b868e --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,20 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: "/" + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 10 + labels: + - dependencies + groups: + stellar: + patterns: + - "@stellar/*" + testing: + patterns: + - "jest*" + - "@types/jest" + - "@testing-library/*" + - "ts-jest" diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml index cf23031..b8a1395 100644 --- a/.github/workflows/web.yml +++ b/.github/workflows/web.yml @@ -28,7 +28,7 @@ jobs: run: npm run typecheck - name: Test - run: npm test + run: npm run test:ci - name: Lint run: npm run lint diff --git a/components/issuer/panels/DistributionPanel.tsx b/components/issuer/panels/DistributionPanel.tsx index 4d3f2cf..5565803 100644 --- a/components/issuer/panels/DistributionPanel.tsx +++ b/components/issuer/panels/DistributionPanel.tsx @@ -3,8 +3,10 @@ import { useState } from "react"; import { StrKey } from "@stellar/stellar-sdk"; import type { AssetDetail } from "@/types"; -import { dividend } from "@/lib/contracts"; +import { assetToken, contractIds, dividend } from "@/lib/contracts"; import { useTx } from "@/hooks/useTx"; +import { useAsync } from "@/hooks/useAsync"; +import { useWallet } from "@/hooks/useWallet"; import { useDividends } from "@/hooks/useDividends"; import { parseTokenAmount, formatTokenAmount, truncateAddress } from "@/lib/format"; import { PAYMENT_TOKEN_DECIMALS } from "@/components/dividend/ClaimButton"; @@ -43,10 +45,44 @@ function CreateDistributionCard({ onCreated?: () => void; }) { const tx = useTx(); + const { address, network } = useWallet(); const [paymentToken, setPaymentToken] = useState(""); const [totalAmount, setTotalAmount] = useState(""); const [formError, setFormError] = useState(null); + // Only fetch when the input looks like a valid contract address. + const isValidPt = + paymentToken.trim().length > 0 && + (StrKey.isValidContract(paymentToken.trim()) || + StrKey.isValidEd25519PublicKey(paymentToken.trim())); + + // #293: Show the issuer's balance in the payment token before they submit. + const { data: balance, loading: balanceLoading } = useAsync( + () => assetToken.balance(network, paymentToken.trim(), address!), + [network, paymentToken, address], + isValidPt && !!address, + ); + + // #293: Show how much the dividend contract is already approved to pull. + // Issuers need to approve at least totalAmount before the distribution can be funded. + const dividendContractId = contractIds(network).dividend; + const { data: allowance, loading: allowanceLoading } = useAsync( + () => assetToken.allowance(network, paymentToken.trim(), address!, dividendContractId), + [network, paymentToken, address, dividendContractId], + isValidPt && !!address, + ); + + // Parse the requested amount for comparison (best-effort; errors handled on submit). + let requestedRaw: bigint | null = null; + try { + if (totalAmount.trim()) requestedRaw = parseTokenAmount(totalAmount, PAYMENT_TOKEN_DECIMALS); + } catch { + // handled at submit time + } + + const insufficientBalance = balance !== null && requestedRaw !== null && requestedRaw > balance; + const needsApproval = allowance !== null && requestedRaw !== null && requestedRaw > allowance; + async function onSubmit(e: React.FormEvent) { e.preventDefault(); setFormError(null); @@ -106,6 +142,51 @@ function CreateDistributionCard({ This is the token used to pay holders — typically a stablecoin or XLM SAC.

+ + {/* #293: surface balance + allowance so the issuer knows before submitting */} + {isValidPt && address && ( +
+
+ Your balance + {balanceLoading ? ( + + ) : balance !== null ? ( + + {formatTokenAmount(balance, PAYMENT_TOKEN_DECIMALS)} + + ) : ( + + )} +
+
+ Dividend contract allowance + {allowanceLoading ? ( + + ) : allowance !== null ? ( + + {formatTokenAmount(allowance, PAYMENT_TOKEN_DECIMALS)} + + ) : ( + + )} +
+
+ )} + + {insufficientBalance && ( +

+ Insufficient balance — your wallet holds less than the requested distribution amount. +

+ )} + {needsApproval && !insufficientBalance && ( +

+ The dividend contract is not approved to spend enough of this token on your behalf. + Submit an approve transaction for at least{" "} + {formatTokenAmount(requestedRaw ?? 0n, PAYMENT_TOKEN_DECIMALS)} tokens before funding + this distribution. +

+ )} +
diff --git a/components/issuer/panels/TokenPanel.tsx b/components/issuer/panels/TokenPanel.tsx index eb41fc2..21c3145 100644 --- a/components/issuer/panels/TokenPanel.tsx +++ b/components/issuer/panels/TokenPanel.tsx @@ -91,6 +91,16 @@ function MintCard({ } > + {/* #294: warn the issuer when the token is paused so mint attempts don't silently fail */} + {metadata.paused && ( +

+ This token is currently paused. Minting will likely fail until the token is + unpaused below. +

+ )}
diff --git a/lib/contracts.ts b/lib/contracts.ts index 685da8e..c23a665 100644 --- a/lib/contracts.ts +++ b/lib/contracts.ts @@ -270,6 +270,22 @@ export const assetToken = { ]).then((v) => BigInt(v ?? 0n)); }, + /** + * Returns the amount of `tokenId` that `spender` is allowed to transfer on + * behalf of `owner` (SEP-41 `allowance`). + */ + allowance( + network: Network, + tokenId: string, + owner: string, + spender: string, + ): Promise { + return readContract(network, tokenId, "allowance", [ + arg.address(owner), + arg.address(spender), + ]).then((v) => BigInt(v ?? 0n)); + }, + totalSupply(network: Network, tokenId: string): Promise { return readContract(network, tokenId, "total_supply").then((v) => BigInt(v ?? 0n),