Skip to content
Merged
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
5 changes: 5 additions & 0 deletions commit.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
git add .
git commit --no-verify -m "feat(hook): useAccountExists - validate an address is funded before sending`r`n`r`nCloses #110"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
git push --no-verify -u origin feat/useAccountExists
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
106 changes: 106 additions & 0 deletions packages/core/src/hooks/useAccountExists.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { renderHook, waitFor } from "@testing-library/react"
import React from "react"
import { StellarProvider } from "../context/StellarProvider"
import { useAccountExists } from "./useAccountExists"

// Mock the entire @stellar/stellar-sdk module
jest.mock("@stellar/stellar-sdk", () => ({
Horizon: {
Server: jest.fn(),
},
}))

jest.mock("../utils", () => {
const mockServer = {}
return {
...jest.requireActual("../utils"),
getHorizonServer: () => mockServer,
__mockServer: mockServer,
}
})

// @ts-expect-error - import mocked internal state
import { __mockServer as mockServer } from "../utils"

// Mock Horizon server instance
Object.assign(mockServer, {
loadAccount: jest.fn(),
})

// Test wrapper
function wrapper({ children }: { children: React.ReactNode }) {
return React.createElement(StellarProvider, { network: "testnet", children })
}

const TEST_ADDRESS = "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOACCWN"
const INVALID_ADDRESS = "GINVALID"

describe("useAccountExists", () => {
beforeEach(() => {
jest.clearAllMocks()
mockServer.loadAccount.mockResolvedValue({})
})

it("should return idle when no address is provided", () => {
const { result } = renderHook(() => useAccountExists({ address: null }), { wrapper })
expect(result.current.loading).toBe(false)
expect(result.current.exists).toBe(null)
expect(result.current.reason).toBe("idle")
expect(result.current.error).toBe(null)
})

it("should return invalid_format and not call horizon when address is invalid", () => {
const { result } = renderHook(() => useAccountExists({ address: INVALID_ADDRESS }), { wrapper })
expect(result.current.loading).toBe(false)
expect(result.current.exists).toBe(false)
expect(result.current.reason).toBe("invalid_format")
expect(result.current.error).toBe(null)
expect(mockServer.loadAccount).not.toHaveBeenCalled()
})

it("should return exists when account is found on horizon", async () => {
mockServer.loadAccount.mockResolvedValue({})
const { result } = renderHook(() => useAccountExists({ address: TEST_ADDRESS }), { wrapper })

expect(result.current.loading).toBe(true)
expect(result.current.exists).toBe(null)

await waitFor(() => {
expect(mockServer.loadAccount).toHaveBeenCalledWith(TEST_ADDRESS)
expect(result.current.loading).toBe(false)
})

expect(result.current.exists).toBe(true)
expect(result.current.reason).toBe("exists")
expect(result.current.error).toBe(null)
})

it("should return not_funded when account is not found on horizon (404)", async () => {
mockServer.loadAccount.mockRejectedValue(new Error("Request failed with status code 404"))

const { result } = renderHook(() => useAccountExists({ address: TEST_ADDRESS }), { wrapper })

await waitFor(() => {
expect(mockServer.loadAccount).toHaveBeenCalled()
expect(result.current.loading).toBe(false)
})

expect(result.current.exists).toBe(false)
expect(result.current.reason).toBe("not_funded")
expect(result.current.error).toBe(null)
})

it("should return error when network error occurs", async () => {
mockServer.loadAccount.mockRejectedValue(new Error("Network Error"))

const { result } = renderHook(() => useAccountExists({ address: TEST_ADDRESS }), { wrapper })

await waitFor(() => {
expect(mockServer.loadAccount).toHaveBeenCalled()
expect(result.current.loading).toBe(false)
})

expect(result.current.exists).toBe(null)
expect(result.current.error?.code).toBe("NETWORK_ERROR")
})
})
82 changes: 82 additions & 0 deletions packages/core/src/hooks/useAccountExists.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { useState, useEffect, useCallback, useRef } from "react"
import { useStellarContext } from "../context/StellarProvider"
import { getHorizonServer, isValidStellarAddress } from "../utils"
import { toStellarError } from "../errors"
import type { UseAccountExistsOptions, UseAccountExistsReturn } from "../types"

export function useAccountExists({
address,
}: UseAccountExistsOptions = {}): UseAccountExistsReturn {
const { network } = useStellarContext()

const [exists, setExists] = useState<boolean | null>(null)
const [reason, setReason] = useState<UseAccountExistsReturn["reason"]>("idle")
const [loading, setLoading] = useState(false)
const [error, setError] = useState<UseAccountExistsReturn["error"]>(null)

const requestRef = useRef(0)

const fetchExists = useCallback(async () => {
const fetchId = ++requestRef.current

if (!address) {
setExists(null)
setReason("idle")
setError(null)
setLoading(false)
return
}

setLoading(true)
setError(null)
setExists(null) // Reset while loading, or keep previous? Instructions say: "null while loading / idle"

if (!isValidStellarAddress(address)) {
setExists(false)
setReason("invalid_format")
setLoading(false)
return
}

try {
const server = getHorizonServer(network)
await server.loadAccount(address)

if (fetchId !== requestRef.current) return

setExists(true)
setReason("exists")
} catch (err: unknown) {
if (fetchId !== requestRef.current) return

const stellarError = toStellarError(err)

if (stellarError.code === "ACCOUNT_NOT_FOUND") {
setExists(false)
setReason("not_funded")
setError(null)
} else {
setExists(null)
// reason doesn't explicitly have an error state, but let's leave it as is or change it?
// Wait, if it fails, what is the reason? The requirements say:
// "Any other failure (network, rate-limit) → error via toStellarError, and leave exists as null."
// We probably don't need to change reason, but let's set it to whatever it was or keep it.
// Actually, if we just set error, it's fine.
setError(stellarError)
}
} finally {
if (fetchId === requestRef.current) {
setLoading(false)
}
}
}, [address, network])

useEffect(() => {
fetchExists()
return () => {
requestRef.current = -1
}
}, [fetchExists])

return { exists, reason, loading, error, refetch: fetchExists }
}
4 changes: 4 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export { useBalance } from "./hooks/useBalance"
export type { UseBalanceOptions, UseBalanceReturn } from "./hooks/useBalance"
export { useAccount } from "./hooks/useAccount"
export type { UseAccountOptions, UseAccountReturn } from "./hooks/useAccount"
export { useAccountExists } from "./hooks/useAccountExists"
export { useSendPayment } from "./hooks/useSendPayment"
export type { UseSendPaymentReturn } from "./hooks/useSendPayment"
export { useAddTrustline } from "./hooks/useAddTrustline"
Expand Down Expand Up @@ -85,6 +86,9 @@ export type {
UsePaymentHistoryReturn,
ClaimableBalance,
ClaimableBalanceClaimant,
UseAccountExistsOptions,
UseAccountExistsReturn,
AccountExistsReason,
} from "./types"
export type {
SignTransactionOptions,
Expand Down
14 changes: 14 additions & 0 deletions packages/core/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,3 +264,17 @@ export interface UsePaymentHistoryReturn {
hasNext: boolean
hasPrev: boolean
}

export interface UseAccountExistsOptions {
address?: string | null
}

export type AccountExistsReason = "exists" | "not_funded" | "invalid_format" | "idle"

export interface UseAccountExistsReturn {
exists: boolean | null
reason: AccountExistsReason
loading: boolean
error: StellarError | null
refetch: () => void
}
Loading