diff --git a/src/rpc/server.ts b/src/rpc/server.ts index 9ca6dbcb4..6ece7c863 100644 --- a/src/rpc/server.ts +++ b/src/rpc/server.ts @@ -1211,6 +1211,9 @@ export class RpcServer { * @see {@link https://developers.stellar.org/docs/learn/fundamentals/networks#friendbot | Friendbot docs} * @see {@link module:Friendbot.Api.Response} * + * @deprecated Use {@link Server.fundAddress} instead, which supports both + * account (G...) and contract (C...) addresses. + * * @example * server * .requestAirdrop("GBZC6Y2Y7Q3ZQ2Y4QZJ2XZ3Z5YXZ6Z7Z2Y4QZJ2XZ3Z5YXZ6Z7Z2Y4") @@ -1262,6 +1265,75 @@ export class RpcServer { } } + /** + * Fund an address using the network's Friendbot faucet, if any. + * + * This method supports both account (G...) and contract (C...) addresses. + * + * @param {string} address The address to fund. Can be either a Stellar + * account (G...) or contract (C...) address. + * @param {string} [friendbotUrl] Optionally, an explicit Friendbot URL + * (by default: this calls the Stellar RPC + * {@link module:rpc.Server#getNetwork | getNetwork} method to try to + * discover this network's Friendbot url). + * @returns {Promise} The transaction + * response from the Friendbot funding transaction. + * @throws {Error} If Friendbot is not configured on this network or the + * funding transaction fails. + * + * @see {@link https://developers.stellar.org/docs/learn/fundamentals/networks#friendbot | Friendbot docs} + * + * @example + * // Funding an account (G... address) + * const tx = await server.fundAddress("GBZC6Y2Y7..."); + * console.log("Funded! Hash:", tx.txHash); + * // If you need the Account object: + * const account = await server.getAccount("GBZC6Y2Y7..."); + * + * @example + * // Funding a contract (C... address) + * const tx = await server.fundAddress("CBZC6Y2Y7..."); + * console.log("Contract funded! Hash:", tx.txHash); + */ + public async fundAddress( + address: string, + friendbotUrl?: string, + ): Promise { + if ( + !StrKey.isValidEd25519PublicKey(address) && + !StrKey.isValidContract(address) + ) { + throw new Error( + `Invalid address: ${address}. Expected a Stellar account (G...) or contract (C...) address.`, + ); + } + + friendbotUrl = friendbotUrl || (await this.getNetwork()).friendbotUrl; + if (!friendbotUrl) { + throw new Error("No friendbot URL configured for current network"); + } + + try { + const response = await this.httpClient.post( + `${friendbotUrl}?addr=${encodeURIComponent(address)}`, + ); + + const txResponse = await this.getTransaction(response.data.hash); + if (txResponse.status !== Api.GetTransactionStatus.SUCCESS) { + throw new Error( + `Funding address ${address} failed: transaction status ${txResponse.status}`, + ); + } + + return txResponse; + } catch (error: any) { + if (error.response?.status === 400) { + throw new Error(error.response.data?.detail ?? "Bad Request"); + } + throw error; + } + } + /** * Provides an analysis of the recent fee stats for regular and smart * contract operations. diff --git a/test/unit/server/soroban/request_airdrop.test.ts b/test/unit/server/soroban/request_airdrop.test.ts index ea513e5bf..dad51cdb7 100644 --- a/test/unit/server/soroban/request_airdrop.test.ts +++ b/test/unit/server/soroban/request_airdrop.test.ts @@ -370,3 +370,240 @@ describe("Server#requestAirdrop", () => { expect(mockPost).toHaveBeenCalledTimes(3); }); }); + +describe("Server#fundAddress", () => { + let server: any; + let mockPost: any; + + // Valid XDR for transaction meta (from get_transaction.test.ts) + const metaV4Xdr = + "AAAAAgAAAAIAAAADAtL5awAAAAAAAAAAS0CFMhOtWUKJWerx66zxkxORaiH6/3RUq7L8zspD5RoAAAAAAcm9QAKVkpMAAHpMAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAwAAAAAC0vi5AAAAAGTB02oAAAAAAAAAAQLS+WsAAAAAAAAAAEtAhTITrVlCiVnq8eus8ZMTkWoh+v90VKuy/M7KQ+UaAAAAAAHJvUAClZKTAAB6TQAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAMAAAAAAtL5awAAAABkwdd1AAAAAAAAAAEAAAAGAAAAAwLS+VQAAAACAAAAAG4cwu71zHNXx3jHCzRGOIthcnfwRgfN2f/AoHFLLMclAAAAAEySDkgAAAAAAAAAAkJVU0lORVNTAAAAAAAAAAC3JfDeo9vreItKNPoe74EkFIqWybeUQNFvLvURhHtskAAAAAAeQtHTL5f6TAAAXH0AAAAAAAAAAAAAAAAAAAABAtL5awAAAAIAAAAAbhzC7vXMc1fHeMcLNEY4i2Fyd/BGB83Z/8CgcUssxyUAAAAATJIOSAAAAAAAAAACQlVTSU5FU1MAAAAAAAAAALcl8N6j2+t4i0o0+h7vgSQUipbJt5RA0W8u9RGEe2yQAAAAAB5C0dNHf4CAAACLCQAAAAAAAAAAAAAAAAAAAAMC0vlUAAAAAQAAAABuHMLu9cxzV8d4xws0RjiLYXJ38EYHzdn/wKBxSyzHJQAAAAJCVVNJTkVTUwAAAAAAAAAAtyXw3qPb63iLSjT6Hu+BJBSKlsm3lEDRby71EYR7bJAAAAAAAABAL3//////////AAAAAQAAAAEAE3H3TnhnuQAAAAAAAAAAAAAAAAAAAAAAAAABAtL5awAAAAEAAAAAbhzC7vXMc1fHeMcLNEY4i2Fyd/BGB83Z/8CgcUssxyUAAAACQlVTSU5FU1MAAAAAAAAAALcl8N6j2+t4i0o0+h7vgSQUipbJt5RA0W8u9RGEe2yQAAAAAAAAQC9//////////wAAAAEAAAABABNx9J6Z4RkAAAAAAAAAAAAAAAAAAAAAAAAAAwLS+WsAAAAAAAAAAG4cwu71zHNXx3jHCzRGOIthcnfwRgfN2f/AoHFLLMclAAAAH37+zXQCXdRTAAASZAAAApIAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAEAAABbBXKIigAAABhZWyiOAAAAAgAAAAAAAAAAAAAAAAAAAAMAAAAAAtL0awAAAABkwbqrAAAAAAAAAAEC0vlrAAAAAAAAAABuHMLu9cxzV8d4xws0RjiLYXJ38EYHzdn/wKBxSyzHJQAAAB9+/s10Al3UUwAAEmQAAAKSAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAABAAAAWwVyiIoAAAAYWVsojgAAAAIAAAAAAAAAAAAAAAAAAAADAAAAAALS9GsAAAAAZMG6qwAAAAAAAAAA"; + + const successTxResponse = (hash: string) => ({ + data: { + id: 1, + result: { + status: "SUCCESS", + txHash: hash, + latestLedger: 100, + latestLedgerCloseTime: 12345, + oldestLedger: 50, + oldestLedgerCloseTime: 500, + ledger: 12345, + createdAt: 123456789010, + applicationOrder: 2, + feeBump: false, + envelopeXdr: + "AAAAAgAAAAAT/LQZdYz0FcQ4Xwyg8IM17rkUx3pPCCWLu+SowQ/T+gBLB24poiQa9iwAngAAAAEAAAAAAAAAAAAAAABkwdeeAAAAAAAAAAEAAAABAAAAAC/9E8hDhnktyufVBS5tqA734Yz5XrLX2XNgBgH/YEkiAAAADQAAAAAAAAAAAAA1/gAAAAAv/RPIQ4Z5Lcrn1QUubagO9+GM+V6y19lzYAYB/2BJIgAAAAAAAAAAAAA1/gAAAAQAAAACU0lMVkVSAAAAAAAAAAAAAFDutWuu6S6UPJBrotNSgfmXa27M++63OT7TYn1qjgy+AAAAAVNHWAAAAAAAUO61a67pLpQ8kGui01KB+Zdrbsz77rc5PtNifWqODL4AAAACUEFMTEFESVVNAAAAAAAAAFDutWuu6S6UPJBrotNSgfmXa27M++63OT7TYn1qjgy+AAAAAlNJTFZFUgAAAAAAAAAAAABQ7rVrrukulDyQa6LTUoH5l2tuzPvutzk+02J9ao4MvgAAAAAAAAACwQ/T+gAAAEA+ztVEKWlqHXNnqy6FXJeHr7TltHzZE6YZm5yZfzPIfLaqpp+5cyKotVkj3d89uZCQNsKsZI48uoyERLne+VwL/2BJIgAAAEA7323gPSaezVSa7Vi0J4PqsnklDH1oHLqNBLwi5EWo5W7ohLGObRVQZ0K0+ufnm4hcm9J4Cuj64gEtpjq5j5cM", + resultXdr: + "AAAAAAAAAGQAAAAAAAAAAQAAAAAAAAANAAAAAAAAAAUAAAACZ4W6fmN63uhVqYRcHET+D2NEtJvhCIYflFh9GqtY+AwAAAACU0lMVkVSAAAAAAAAAAAAAFDutWuu6S6UPJBrotNSgfmXa27M++63OT7TYn1qjgy+AAAYW0toL2gAAAAAAAAAAAAANf4AAAACcgyAkXD5kObNTeRYciLh7R6ES/zzKp0n+cIK3Y6TjBkAAAABU0dYAAAAAABQ7rVrrukulDyQa6LTUoH5l2tuzPvutzk+02J9ao4MvgAAGlGnIJrXAAAAAlNJTFZFUgAAAAAAAAAAAABQ7rVrrukulDyQa6LTUoH5l2tuzPvutzk+02J9ao4MvgAAGFtLaC9oAAAAApmc7UgUBInrDvij8HMSridx2n1w3I8TVEn4sLr1LSpmAAAAAlBBTExBRElVTQAAAAAAAABQ7rVrrukulDyQa6LTUoH5l2tuzPvutzk+02J9ao4MvgAAIUz88EqYAAAAAVNHWAAAAAAAUO61a67pLpQ8kGui01KB+Zdrbsz77rc5PtNifWqODL4AABpRpyCa1wAAAAKYUsaaCZ233xB1p+lG7YksShJWfrjsmItbokiR3ifa0gAAAAJTSUxWRVIAAAAAAAAAAAAAUO61a67pLpQ8kGui01KB+Zdrbsz77rc5PtNifWqODL4AABv52PPa5wAAAAJQQUxMQURJVU0AAAAAAAAAUO61a67pLpQ8kGui01KB+Zdrbsz77rc5PtNifWqODL4AACFM/PBKmAAAAAJnhbp+Y3re6FWphFwcRP4PY0S0m+EIhh+UWH0aq1j4DAAAAAAAAAAAAAA9pAAAAAJTSUxWRVIAAAAAAAAAAAAAUO61a67pLpQ8kGui01KB+Zdrbsz77rc5PtNifWqODL4AABv52PPa5wAAAAAv/RPIQ4Z5Lcrn1QUubagO9+GM+V6y19lzYAYB/2BJIgAAAAAAAAAAAAA9pAAAAAA=", + resultMetaXdr: metaV4Xdr, + events: { + contractEventsXdr: [], + transactionEventsXdr: [], + }, + }, + }, + }); + + beforeEach(() => { + server = new Server(serverUrl); + mockPost = vi.spyOn(server.httpClient, "post"); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("funds an account address (G...) and returns transaction response", async () => { + const friendbotUrl = "https://friendbot.stellar.org"; + const accountId = + "GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI"; + const hash = + "ae9f315c048d87a5f853bc15bf284a2c3c89eb0e1cb38c10409b77a877b830a8"; + + const networkResult = { + friendbotUrl, + passphrase: Networks.FUTURENET, + protocolVersion: 20, + }; + const networkResponse = { data: { result: networkResult } }; + + const friendbotResponse = { + status: 200, + data: { hash }, + }; + + mockPost + .mockResolvedValueOnce(networkResponse) // getNetwork call + .mockResolvedValueOnce(friendbotResponse) // friendbot call + .mockResolvedValueOnce(successTxResponse(hash)); // getTransaction call + + const result = await server.fundAddress(accountId); + + expect(result.status).toBe("SUCCESS"); + expect(result.txHash).toBe(hash); + expect(mockPost).toHaveBeenCalledWith(`${friendbotUrl}?addr=${accountId}`); + expect(mockPost).toHaveBeenCalledTimes(3); + }); + + it("funds a contract address (C...) and returns transaction response", async () => { + const friendbotUrl = "https://friendbot.stellar.org"; + const contractId = StellarSdk.StrKey.encodeContract( + Buffer.from("0".repeat(64), "hex"), + ); + const hash = + "ae9f315c048d87a5f853bc15bf284a2c3c89eb0e1cb38c10409b77a877b830a8"; + + const networkResult = { + friendbotUrl, + passphrase: Networks.FUTURENET, + protocolVersion: 20, + }; + const networkResponse = { data: { result: networkResult } }; + + const friendbotResponse = { + status: 200, + data: { hash }, + }; + + mockPost + .mockResolvedValueOnce(networkResponse) // getNetwork call + .mockResolvedValueOnce(friendbotResponse) // friendbot call + .mockResolvedValueOnce(successTxResponse(hash)); // getTransaction call + + const result = await server.fundAddress(contractId); + + expect(result.status).toBe("SUCCESS"); + expect(result.txHash).toBe(hash); + expect(mockPost).toHaveBeenCalledWith(`${friendbotUrl}?addr=${contractId}`); + expect(mockPost).toHaveBeenCalledTimes(3); + }); + + it("uses custom friendbot URL when provided", async () => { + const customFriendbotUrl = "https://custom-friendbot.example.com"; + const accountId = + "GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI"; + const hash = "somehash123"; + + const friendbotResponse = { + status: 200, + data: { hash }, + }; + + mockPost + .mockResolvedValueOnce(friendbotResponse) // friendbot call + .mockResolvedValueOnce(successTxResponse(hash)); // getTransaction call + + const result = await server.fundAddress(accountId, customFriendbotUrl); + + expect(result.status).toBe("SUCCESS"); + // Should not call getNetwork when custom URL provided + expect(mockPost).not.toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ method: "getNetwork" }), + ); + expect(mockPost).toHaveBeenCalledWith( + `${customFriendbotUrl}?addr=${accountId}`, + ); + expect(mockPost).toHaveBeenCalledTimes(2); + }); + + it("throws error when no friendbot URL is configured", async () => { + const accountId = + "GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI"; + + const networkResult = { + friendbotUrl: undefined, + passphrase: Networks.FUTURENET, + protocolVersion: 20, + }; + const networkResponse = { data: { result: networkResult } }; + + mockPost.mockResolvedValueOnce(networkResponse); + + await expect(server.fundAddress(accountId)).rejects.toThrow( + "No friendbot URL configured for current network", + ); + }); + + it("throws error when funding fails", async () => { + const friendbotUrl = "https://friendbot.stellar.org"; + const accountId = + "GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI"; + const hash = "somehash123"; + + const networkResult = { + friendbotUrl, + passphrase: Networks.FUTURENET, + protocolVersion: 20, + }; + const networkResponse = { data: { result: networkResult } }; + + const friendbotResponse = { + status: 200, + data: { hash }, + }; + + const failedTxResponse = { + data: { + id: 1, + result: { + status: "FAILED", + txHash: hash, + latestLedger: 100, + latestLedgerCloseTime: 12345, + oldestLedger: 50, + oldestLedgerCloseTime: 500, + ledger: 12345, + createdAt: 123456789010, + applicationOrder: 2, + feeBump: false, + envelopeXdr: + "AAAAAgAAAAAT/LQZdYz0FcQ4Xwyg8IM17rkUx3pPCCWLu+SowQ/T+gBLB24poiQa9iwAngAAAAEAAAAAAAAAAAAAAABkwdeeAAAAAAAAAAEAAAABAAAAAC/9E8hDhnktyufVBS5tqA734Yz5XrLX2XNgBgH/YEkiAAAADQAAAAAAAAAAAAA1/gAAAAAv/RPIQ4Z5Lcrn1QUubagO9+GM+V6y19lzYAYB/2BJIgAAAAAAAAAAAAA1/gAAAAQAAAACU0lMVkVSAAAAAAAAAAAAAFDutWuu6S6UPJBrotNSgfmXa27M++63OT7TYn1qjgy+AAAAAVNHWAAAAAAAUO61a67pLpQ8kGui01KB+Zdrbsz77rc5PtNifWqODL4AAAACUEFMTEFESVVNAAAAAAAAAFDutWuu6S6UPJBrotNSgfmXa27M++63OT7TYn1qjgy+AAAAAlNJTFZFUgAAAAAAAAAAAABQ7rVrrukulDyQa6LTUoH5l2tuzPvutzk+02J9ao4MvgAAAAAAAAACwQ/T+gAAAEA+ztVEKWlqHXNnqy6FXJeHr7TltHzZE6YZm5yZfzPIfLaqpp+5cyKotVkj3d89uZCQNsKsZI48uoyERLne+VwL/2BJIgAAAEA7323gPSaezVSa7Vi0J4PqsnklDH1oHLqNBLwi5EWo5W7ohLGObRVQZ0K0+ufnm4hcm9J4Cuj64gEtpjq5j5cM", + resultXdr: + "AAAAAAAAAGQAAAAAAAAAAQAAAAAAAAANAAAAAAAAAAUAAAACZ4W6fmN63uhVqYRcHET+D2NEtJvhCIYflFh9GqtY+AwAAAACU0lMVkVSAAAAAAAAAAAAAFDutWuu6S6UPJBrotNSgfmXa27M++63OT7TYn1qjgy+AAAYW0toL2gAAAAAAAAAAAAANf4AAAACcgyAkXD5kObNTeRYciLh7R6ES/zzKp0n+cIK3Y6TjBkAAAABU0dYAAAAAABQ7rVrrukulDyQa6LTUoH5l2tuzPvutzk+02J9ao4MvgAAGlGnIJrXAAAAAlNJTFZFUgAAAAAAAAAAAABQ7rVrrukulDyQa6LTUoH5l2tuzPvutzk+02J9ao4MvgAAGFtLaC9oAAAAApmc7UgUBInrDvij8HMSridx2n1w3I8TVEn4sLr1LSpmAAAAAlBBTExBRElVTQAAAAAAAABQ7rVrrukulDyQa6LTUoH5l2tuzPvutzk+02J9ao4MvgAAIUz88EqYAAAAAVNHWAAAAAAAUO61a67pLpQ8kGui01KB+Zdrbsz77rc5PtNifWqODL4AABpRpyCa1wAAAAKYUsaaCZ233xB1p+lG7YksShJWfrjsmItbokiR3ifa0gAAAAJTSUxWRVIAAAAAAAAAAAAAUO61a67pLpQ8kGui01KB+Zdrbsz77rc5PtNifWqODL4AABv52PPa5wAAAAJQQUxMQURJVU0AAAAAAAAAUO61a67pLpQ8kGui01KB+Zdrbsz77rc5PtNifWqODL4AACFM/PBKmAAAAAJnhbp+Y3re6FWphFwcRP4PY0S0m+EIhh+UWH0aq1j4DAAAAAAAAAAAAAA9pAAAAAJTSUxWRVIAAAAAAAAAAAAAUO61a67pLpQ8kGui01KB+Zdrbsz77rc5PtNifWqODL4AABv52PPa5wAAAAAv/RPIQ4Z5Lcrn1QUubagO9+GM+V6y19lzYAYB/2BJIgAAAAAAAAAAAAA9pAAAAAA=", + resultMetaXdr: metaV4Xdr, + }, + }, + }; + + mockPost + .mockResolvedValueOnce(networkResponse) + .mockResolvedValueOnce(friendbotResponse) + .mockResolvedValueOnce(failedTxResponse); + + await expect(server.fundAddress(accountId)).rejects.toThrow( + `Funding address ${accountId} failed: transaction status FAILED`, + ); + }); + + it("throws error when HTTP request fails", async () => { + const friendbotUrl = "https://friendbot.stellar.org"; + const accountId = + "GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI"; + + const networkResult = { + friendbotUrl, + passphrase: Networks.FUTURENET, + protocolVersion: 20, + }; + const networkResponse = { data: { result: networkResult } }; + + mockPost + .mockResolvedValueOnce(networkResponse) + .mockRejectedValueOnce(new Error("Network error")); + + await expect(server.fundAddress(accountId)).rejects.toThrow( + "Network error", + ); + }); + + it("rejects invalid addresses", async () => { + const invalidAddress = "INVALID_ADDRESS"; + + await expect(server.fundAddress(invalidAddress)).rejects.toThrow( + "Invalid address: INVALID_ADDRESS. Expected a Stellar account (G...) or contract (C...) address.", + ); + }); +});