From d5dd0f2a4f234c584c6cce9c277bc72e1084b1e9 Mon Sep 17 00:00:00 2001 From: Tanveer Wahid Date: Thu, 23 Jul 2026 14:24:01 -0700 Subject: [PATCH 1/8] feat(wasi): expose the build_fee_block --- .../java/network/keeta/wasi/FeeRound.java | 35 ++++ .../network/keeta/wasi/GenerateFeeBlock.java | 13 ++ .../network/keeta/wasi/TransmitOptions.java | 62 ++++++ .../java/network/keeta/wasi/UserClient.java | 165 +++++++++++----- .../keeta/wasi/harness/FeeTransfer.java | 187 +++++++++++++++--- .../host-tests/tests/java_fee.rs | 8 + keetanetwork-client-wasi/src/p1/mod.rs | 43 ++++ keetanetwork-client-wasi/src/p2/mod.rs | 2 +- keetanetwork-client-wasi/src/pure.rs | 103 +++++++++- keetanetwork-client-wasm/src/options.rs | 8 +- keetanetwork-client-wasm/src/user.rs | 8 +- keetanetwork-client/src/client.rs | 119 +++++++---- keetanetwork-client/src/lib.rs | 4 +- keetanetwork-client/src/model.rs | 92 ++++++++- keetanetwork-client/src/user.rs | 26 ++- keetanetwork-client/tests/e2e.rs | 167 +++++++++++++++- 16 files changed, 910 insertions(+), 132 deletions(-) create mode 100644 keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/FeeRound.java create mode 100644 keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/GenerateFeeBlock.java create mode 100644 keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/TransmitOptions.java diff --git a/keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/FeeRound.java b/keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/FeeRound.java new file mode 100644 index 0000000..72252b1 --- /dev/null +++ b/keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/FeeRound.java @@ -0,0 +1,35 @@ +package network.keeta.wasi; + +import java.util.List; + +/** + * The temporary-round context handed to a {@link GenerateFeeBlock} factory: + * the blocks being published, the node's temporary vote declaring the fee, + * and the caller's fee-token preferences. + */ +public final class FeeRound { + private final List blocks; + private final String temporaryVoteBase64; + private final List feeTokenPriority; + + FeeRound(List blocks, String temporaryVoteBase64, List feeTokenPriority) { + this.blocks = blocks; + this.temporaryVoteBase64 = temporaryVoteBase64; + this.feeTokenPriority = feeTokenPriority; + } + + /** The blocks of the temporary round the fee block will join. */ + public List blocks() { + return blocks; + } + + /** The node's temporary vote (base64) declaring the required fee. */ + public String temporaryVoteBase64() { + return temporaryVoteBase64; + } + + /** Preferred fee tokens, highest priority first; may be empty. */ + public List feeTokenPriority() { + return feeTokenPriority; + } +} diff --git a/keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/GenerateFeeBlock.java b/keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/GenerateFeeBlock.java new file mode 100644 index 0000000..b4e40e6 --- /dev/null +++ b/keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/GenerateFeeBlock.java @@ -0,0 +1,13 @@ +package network.keeta.wasi; + +/** + * Caller-supplied fee-block factory, invoked mid-transmit with the temporary + * round; the returned block joins the permanent round and the staple. + * Receives the transmitting client so it can chain through + * {@link UserClient#buildFeeBlock(FeeRound, Account, Account)}. Return + * {@code null} to publish without a fee block (no fee owed). + */ +@FunctionalInterface +public interface GenerateFeeBlock { + Block.SignedBlock generate(UserClient client, FeeRound round); +} diff --git a/keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/TransmitOptions.java b/keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/TransmitOptions.java new file mode 100644 index 0000000..91c7782 --- /dev/null +++ b/keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/TransmitOptions.java @@ -0,0 +1,62 @@ +package network.keeta.wasi; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Publish-time options for {@link UserClient#transmit(List, TransmitOptions)}. + * Fee payment is a {@link GenerateFeeBlock} factory; + * {@link #withFeeSigner(Account)} and + * {@link #withFeeBlockFrom(Account, Account)} cover the common shapes. + */ +public final class TransmitOptions { + private final List feeTokenPriority = new ArrayList<>(); + private GenerateFeeBlock generateFeeBlock; + + private TransmitOptions() { + } + + /** Options paying no fee: a vote requiring one fails with {@code FEE_REQUIRED}. */ + public static TransmitOptions defaults() { + return new TransmitOptions(); + } + + /** + * Append a token to the fee-token preference order, highest priority + * first, used when a fee is payable in several tokens. + */ + public TransmitOptions addFeeTokenPriority(Account token) { + this.feeTokenPriority.add(token); + return this; + } + + /** Pay any required fee from {@code signer}, signing for itself. */ + public TransmitOptions withFeeSigner(Account signer) { + return withFeeBlockFrom(signer, signer); + } + + /** + * Pay any required fee from {@code account}, signed by {@code signer} + * (delegated signing, e.g. a storage account whose owner signs). For a + * payer that signs for itself, prefer {@link #withFeeSigner(Account)}. + */ + public TransmitOptions withFeeBlockFrom(Account account, Account signer) { + this.generateFeeBlock = (client, round) -> client.buildFeeBlock(round, account, signer); + return this; + } + + /** Install a hand-written fee-block factory for exotic payment flows. */ + public TransmitOptions withGenerateFeeBlock(GenerateFeeBlock factory) { + this.generateFeeBlock = factory; + return this; + } + + List feeTokenPriority() { + return Collections.unmodifiableList(feeTokenPriority); + } + + GenerateFeeBlock generateFeeBlock() { + return generateFeeBlock; + } +} diff --git a/keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/UserClient.java b/keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/UserClient.java index bba3e5f..02f8474 100644 --- a/keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/UserClient.java +++ b/keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/UserClient.java @@ -22,6 +22,7 @@ public final class UserClient { private final Keeta keeta; private final KeetaNet net; private final long network; + private final Account baseToken; private final NodeApi nodeApi; private final LedgerApi ledgerApi; private final VoteApi voteApi; @@ -30,9 +31,15 @@ public final class UserClient { this.keeta = keeta; this.net = keeta.runtime(); this.network = network; + this.baseToken = new Account(net, net.handle("keeta_base_token", network)); + + String baseUri = api; + if (baseUri.endsWith("/")) { + baseUri = baseUri.substring(0, baseUri.length() - 1); + } ApiClient client = new ApiClient(); - client.updateBaseUri(api.endsWith("/") ? api.substring(0, api.length() - 1) : api); + client.updateBaseUri(baseUri); this.nodeApi = new NodeApi(client); this.ledgerApi = new LedgerApi(client); this.voteApi = new VoteApi(client); @@ -43,6 +50,11 @@ public long network() { return network; } + /** The network's base token (the implicit fee currency), derived from the network id. */ + public Account baseToken() { + return baseToken; + } + /** The node software version string. */ public String nodeVersion() { return attempt(() -> nodeApi.getNodeVersion().getNode(), "node version"); @@ -62,7 +74,11 @@ public String headHash(Account account) { public String supply(Account token) { return attempt(() -> { var info = ledgerApi.getAccountState(token.publicKeyString()).getInfo(); - return info == null ? null : info.getSupply(); + if (info == null) { + return null; + } + + return info.getSupply(); }, "token supply"); } @@ -74,25 +90,33 @@ public void transmit(Block.SignedBlock block) { } /** - * Publish several signed blocks as one atomic staple, paying no fee. + * Publish several signed blocks as one atomic staple, paying no fee: a + * vote requiring one fails with {@code FEE_REQUIRED}. */ public void transmit(List blocks) { - transmit(blocks, null, null); + transmit(blocks, TransmitOptions.defaults()); } /** - * Publish {@code blocks} as one atomic staple. Request a temporary vote - * covering every block; when it declares a required fee and both - * {@code feeSigner} and {@code baseToken} are supplied, originate a fee block - * paying it. + * Publish {@code blocks} as one atomic staple. When {@code options} + * carries a fee-block factory it is invoked with the temporary round, and + * any block it returns joins the permanent round and the staple. Without + * a factory, a vote requiring a fee fails with {@code FEE_REQUIRED} + * before anything is published. */ - public void transmit(List blocks, Account feeSigner, Account baseToken) { + public void transmit(List blocks, TransmitOptions options) { List encoded = encode(blocks); String temporary = requestVote(encoded, null); - Block.SignedBlock feeBlock = (feeSigner == null || baseToken == null) - ? null - : buildFeeBlock(feeSigner, baseToken, blocks, temporary); + GenerateFeeBlock factory = options.generateFeeBlock(); + if (factory == null && feesRequired(temporary)) { + throw new KeetaException("FEE_REQUIRED", "votes require a fee but no fee-block factory was supplied"); + } + + Block.SignedBlock feeBlock = null; + if (factory != null) { + feeBlock = factory.generate(this, new FeeRound(blocks, temporary, options.feeTokenPriority())); + } try { List all = blocks; @@ -137,45 +161,42 @@ private void publishStaple(List blocks, String permanentVoteB int blocksPtr = net.writeHandles(blockHandles); int votesPtr = net.writeHandles(voteHandle); - int stapleHandle = net.handle("keeta_vote_staple_build", blocksPtr, blockHandles.length * 4, votesPtr, 4, - System.currentTimeMillis()); + long currentTime = System.currentTimeMillis(); + int stapleHandle = net.handle("keeta_vote_staple_build", blocksPtr, blockHandles.length * 4, votesPtr, 4, currentTime); byte[] staple = net.takeBytes(stapleHandle); String stapleBase64 = Base64.getEncoder().encodeToString(staple); - attempt(() -> nodeApi.publishVoteStaple(new PublishVoteStapleRequest().votesAndBlocks(stapleBase64)), - "publish"); + attempt(() -> nodeApi.publishVoteStaple(new PublishVoteStapleRequest().votesAndBlocks(stapleBase64)), "publish"); } finally { net.free("keeta_vote_free", voteHandle); } } /** - * Build and sign the fee block paying {@code temporaryVoteBase64}'s required - * fee in {@code baseToken}, chained atop {@code feeSigner}'s block in the - * staple (or its ledger head). Returns {@code null} when no fee is owed. + * Build and sign a fee block paying {@code round}'s required fee: + * {@code account}'s balance pays, {@code signer} signs. */ - private Block.SignedBlock buildFeeBlock(Account feeSigner, Account baseToken, List blocks, - String temporaryVoteBase64) { - byte[] voteBytes = Base64.getDecoder().decode(temporaryVoteBase64); - int votePtr = net.write(voteBytes); - int voteHandle = net.handle("keeta_vote_from_bytes", votePtr, voteBytes.length); + public Block.SignedBlock buildFeeBlock(FeeRound round, Account account, Account signer) { + int voteHandle = voteHandle(round.temporaryVoteBase64()); try { - int feeOpHandle = net.callInt("keeta_fee_send", voteHandle, baseToken.handle(), 0, 0); + int feeOpHandle = feeSend(voteHandle, round.feeTokenPriority()); if (feeOpHandle == 0) { return null; } - String previous = feeBlockPrevious(feeSigner, blocks); + String previous = tipHashFor(account, round.blocks()); + if (previous == null) { + previous = headHash(account); + } + Block.Builder builder = keeta.builder() .version(2) .network(network) - .account(feeSigner) - .signer(feeSigner) + .account(account) + .signer(signer) .purpose("fee") .date(System.currentTimeMillis()); - Block.Builder positioned = (previous == null || previous.isBlank()) - ? builder.opening() - : builder.previous(hexDecode(previous)); + Block.Builder positioned = positionAfter(builder, previous); try (Operation feeOp = new Operation(net, feeOpHandle); Block.UnsignedBlock unsigned = positioned.addOperation(feeOp).build()) { @@ -186,19 +207,59 @@ private Block.SignedBlock buildFeeBlock(Account feeSigner, Account baseToken, Li } } - /** The fee block's previous: {@code feeSigner}'s last block in {@code blocks}, else its ledger head. */ - private String feeBlockPrevious(Account feeSigner, List blocks) { - String signerAddress = feeSigner.publicKeyString(); - String previous = null; - for (Block.SignedBlock block : blocks) { - try (Account account = block.account()) { - if (account.publicKeyString().equals(signerAddress)) { - previous = block.hashHex(); - } + /** Decode a base64 vote into a guest vote handle. */ + private int voteHandle(String voteBase64) { + byte[] voteBytes = Base64.getDecoder().decode(voteBase64); + int votePtr = net.write(voteBytes); + + return net.handle("keeta_vote_from_bytes", votePtr, voteBytes.length); + } + + /** Whether the base64 vote obliges a fee block. */ + private boolean feesRequired(String voteBase64) { + int voteHandle = voteHandle(voteBase64); + try { + return net.callInt("keeta_fees_required", voteHandle) != 0; + } finally { + net.free("keeta_vote_free", voteHandle); + } + } + + /** + * The fee-paying operation handle the vote requires in the base token, + * honoring the {@code priority} token preference; 0 when no fee is owed. + */ + private int feeSend(int voteHandle, List priority) { + int priorityPtr = 0; + int priorityLen = 0; + if (!priority.isEmpty()) { + int[] priorityHandles = new int[priority.size()]; + for (int index = 0; index < priorityHandles.length; index++) { + priorityHandles[index] = priority.get(index).handle(); } + + priorityPtr = net.writeHandles(priorityHandles); + priorityLen = priorityHandles.length * 4; } - return previous == null ? headHash(feeSigner) : previous; + return net.callInt("keeta_fee_send", voteHandle, baseToken.handle(), priorityPtr, priorityLen); + } + + /** {@code payer}'s last block hash (hex) among {@code blocks}, or {@code null} when absent. */ + private String tipHashFor(Account payer, List blocks) { + int[] blockHandles = new int[blocks.size()]; + for (int index = 0; index < blockHandles.length; index++) { + blockHandles[index] = blocks.get(index).handle(); + } + + int blocksPtr = net.writeHandles(blockHandles); + int tipHandle = net.callInt("keeta_blocks_tip_for", blocksPtr, blockHandles.length * 4, payer.handle()); + + if (tipHandle == 0) { + return null; + } + + return net.takeString(tipHandle); } /** @@ -233,14 +294,13 @@ public String modifyCertificateRemove(Account account, String hash) { */ private Block.SignedBlock buildSigned(Account account, Operation operation) { String head = headHash(account); - boolean opening = head == null || head.isBlank(); Block.Builder builder = keeta.builder() .version(2) .network(network) .account(account) .signer(account) .date(System.currentTimeMillis()); - Block.Builder positioned = opening ? builder.opening() : builder.previous(hexDecode(head)); + Block.Builder positioned = positionAfter(builder, head); try (Operation owned = operation; Block.UnsignedBlock unsigned = positioned.addOperation(owned).build()) { @@ -248,6 +308,18 @@ private Block.SignedBlock buildSigned(Account account, Operation operation) { } } + /** + * Position {@code builder} atop {@code previous}, or as an opening block + * when the account has no chain yet ({@code previous} null or blank). + */ + private static Block.Builder positionAfter(Block.Builder builder, String previous) { + if (previous == null || previous.isBlank()) { + return builder.opening(); + } + + return builder.previous(hexDecode(previous)); + } + private String publish(Block.SignedBlock block) { try (block) { String hash = block.hashHex(); @@ -271,9 +343,14 @@ private String requestVote(List blocksBase64, String priorVoteBase64) { // the optional field unset, so the generated client (mapper NON_NULL) // drops it from the body. Round two attaches the temporary vote so the // representative escalates it. + List priorVotes = null; + if (priorVoteBase64 != null) { + priorVotes = List.of(priorVoteBase64); + } + CreateVoteRequest request = new CreateVoteRequest() .blocks(blocksBase64) - .votes(priorVoteBase64 == null ? null : List.of(priorVoteBase64)); + .votes(priorVotes); CreateVoteResponse response = attempt(() -> voteApi.createVote(request), "vote"); Vote vote = response.getVote(); diff --git a/keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/harness/FeeTransfer.java b/keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/harness/FeeTransfer.java index 7c5ba09..dda0702 100644 --- a/keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/harness/FeeTransfer.java +++ b/keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/harness/FeeTransfer.java @@ -4,21 +4,36 @@ import java.util.List; import network.keeta.wasi.Account; +import network.keeta.wasi.AdjustMethod; import network.keeta.wasi.Algorithm; import network.keeta.wasi.Block; +import network.keeta.wasi.IdentifierType; import network.keeta.wasi.Keeta; +import network.keeta.wasi.KeetaException; import network.keeta.wasi.Operation; +import network.keeta.wasi.Permissions; +import network.keeta.wasi.TransmitOptions; import network.keeta.wasi.UserClient; /** * End-to-end fee harness test exercising the bound Java SDK against a fee-enforcing * node. * - *

The node charges a flat base-token fee on every transaction, so a fee-less - * staple is rejected. This sends base tokens with a fee-aware transmit: the - * sender originates a fee block paying the representative, proving the - * {@code keeta_fee_send} path. It then confirms the recipient was credited and - * the sender was debited the amount plus the fee. + *

The node charges a flat base-token fee on every transaction. Three probes + * mirror the Rust client's fee e2e tests: + * + *

    + *
  1. The sender pays its own fee through + * {@code TransmitOptions.withFeeSigner}, proving the {@code keeta_fee_send} + * path.
  2. + *
  3. A storage account (no key of its own) pays the fee with its trusted + * owner signing the fee block through + * {@code TransmitOptions.withFeeBlockFrom}, proving the delegated + * account/signer split.
  4. + *
  5. A fee-less transmit fails with a typed {@code FEE_REQUIRED} before + * anything is published. Last: the node's temporary vote pins the account + * head until it expires, blocking any further block on that head.
  6. + *
*/ public final class FeeTransfer { private static final BigInteger AMOUNT = BigInteger.valueOf(100); @@ -38,37 +53,157 @@ public static void main(String[] args) { try (Account trusted = keeta.account(trustedSeed, 0, Algorithm.ED25519); Account recipient = keeta.account(trustedSeed, 7, Algorithm.ED25519); + Account storageRecipient = keeta.account(trustedSeed, 8, Algorithm.ED25519); Account base = keeta.accountFromPublicKeyString(baseTokenAddress)) { String head = client.headHash(trusted); check(head != null && !head.isBlank(), "funded account must have a head block"); + check(client.baseToken().publicKeyString().equals(base.publicKeyString()), + "the derived base token must match the node's"); - BigInteger senderBefore = parseHex(client.balance(trusted, base)); + senderPaysOwnFee(keeta, client, trusted, recipient, base, fee); + storageAccountPaysFee(keeta, client, trusted, storageRecipient, base, fee); + feeRequiredIsTyped(keeta, client, trusted, recipient, base); + } + } + } - Block.SignedBlock send; - try (Operation op = keeta.send(recipient, AMOUNT, base, ""); - Block.UnsignedBlock unsigned = keeta.builder() - .version(2).network(network).account(trusted).signer(trusted) - .previous(hexDecode(head)).date(System.currentTimeMillis()) - .addOperation(op).build()) { - send = unsigned.sign(); - } + /** + * A fee-less transmit must fail with a typed {@code FEE_REQUIRED} before + * anything is published: the recipient's balance is unchanged after. + */ + private static void feeRequiredIsTyped(Keeta keeta, UserClient client, Account trusted, Account recipient, + Account base) { + BigInteger recipientBefore = parseHex(client.balance(recipient, base)); + + Block.SignedBlock send = sendBlock(keeta, client, trusted, recipient, AMOUNT, base); + try (send) { + client.transmit(List.of(send)); + check(false, "a fee-less transmit must throw FEE_REQUIRED"); + } catch (KeetaException exception) { + check("FEE_REQUIRED".equals(exception.code()), + "a fee-less transmit must fail with FEE_REQUIRED, got " + exception.code()); + } - try (send) { - // Fee-aware: a fee-less staple would be rejected by this node. - client.transmit(List.of(send), trusted, base); - } + BigInteger recipientAfter = parseHex(client.balance(recipient, base)); + check(recipientAfter.equals(recipientBefore), "a refused transmit must not move funds"); + System.out.println("[harness] FEE_REQUIRED_OK"); + } - BigInteger recipientBalance = parseHex(client.balance(recipient, base)); - check(recipientBalance.equals(AMOUNT), - "recipient must be credited the amount, got " + recipientBalance); + /** + * The sender pays its own required fee: the sender is debited the amount + * plus the fee and the recipient is credited the amount. + */ + private static void senderPaysOwnFee(Keeta keeta, UserClient client, Account trusted, Account recipient, + Account base, BigInteger fee) { + BigInteger senderBefore = parseHex(client.balance(trusted, base)); + + Block.SignedBlock send = sendBlock(keeta, client, trusted, recipient, AMOUNT, base); + try (send) { + // Fee-aware: this node rejects a fee-less staple. + client.transmit(List.of(send), TransmitOptions.defaults().withFeeSigner(trusted)); + } + + BigInteger recipientBalance = parseHex(client.balance(recipient, base)); + check(recipientBalance.equals(AMOUNT), + "recipient must be credited the amount, got " + recipientBalance); + + BigInteger senderAfter = parseHex(client.balance(trusted, base)); + BigInteger debited = senderBefore.subtract(senderAfter); + check(debited.equals(AMOUNT.add(fee)), + "sender must be debited amount plus fee (" + AMOUNT.add(fee) + "), got " + debited); + + System.out.println("[harness] FEE_OK debited=" + debited + " fee=" + fee); + } + + /** + * A storage account pays the required fee while its trusted owner signs + * the fee block: the sender is debited only the amount and the storage + * account exactly the fee. + */ + private static void storageAccountPaysFee(Keeta keeta, UserClient client, Account trusted, Account recipient, + Account base, BigInteger fee) { + BigInteger funding = fee.multiply(BigInteger.TEN); + TransmitOptions trustedPays = TransmitOptions.defaults().withFeeSigner(trusted); + + String head = client.headHash(trusted); + try (Account storage = trusted.generateIdentifier(IdentifierType.STORAGE, hexDecode(head), 0)) { + createStorage(keeta, client, trusted, storage, head, trustedPays); + grantHold(keeta, client, trusted, storage, base, trustedPays); + + Block.SignedBlock fund = sendBlock(keeta, client, trusted, storage, funding, base); + try (fund) { + client.transmit(List.of(fund), trustedPays); + } - BigInteger senderAfter = parseHex(client.balance(trusted, base)); - BigInteger debited = senderBefore.subtract(senderAfter); - check(debited.equals(AMOUNT.add(fee)), - "sender must be debited amount plus fee (" + AMOUNT.add(fee) + "), got " + debited); + BigInteger senderBefore = parseHex(client.balance(trusted, base)); + BigInteger storageBefore = parseHex(client.balance(storage, base)); - System.out.println("[harness] FEE_OK debited=" + debited + " fee=" + fee); + Block.SignedBlock send = sendBlock(keeta, client, trusted, recipient, AMOUNT, base); + try (send) { + // Delegated: the storage account pays, its owner signs. + client.transmit(List.of(send), TransmitOptions.defaults().withFeeBlockFrom(storage, trusted)); } + + BigInteger senderDebit = senderBefore.subtract(parseHex(client.balance(trusted, base))); + check(senderDebit.equals(AMOUNT), + "sender must be debited only the amount when storage pays the fee, got " + senderDebit); + + BigInteger storageDebit = storageBefore.subtract(parseHex(client.balance(storage, base))); + check(storageDebit.equals(fee), + "storage payer must be debited exactly the fee (" + fee + "), got " + storageDebit); + + BigInteger recipientBalance = parseHex(client.balance(recipient, base)); + check(recipientBalance.equals(AMOUNT), + "recipient must be credited the amount, got " + recipientBalance); + + System.out.println("[harness] STORAGE_FEE_OK storageDebit=" + storageDebit + " fee=" + fee); + } + } + + /** Publish the block creating {@code storage} under {@code trusted}. */ + private static void createStorage(Keeta keeta, UserClient client, Account trusted, Account storage, + String head, TransmitOptions trustedPays) { + try (Operation create = keeta.createIdentifier(storage); + Block.UnsignedBlock unsigned = keeta.builder() + .version(2).network(client.network()).account(trusted).signer(trusted) + .previous(hexDecode(head)).date(System.currentTimeMillis()) + .addOperation(create).build(); + Block.SignedBlock block = unsigned.sign()) { + client.transmit(List.of(block), trustedPays); + } + } + + /** + * Grant {@code storage} the {@code STORAGE_CAN_HOLD} permission for the + * base token (a storage account may only hold tokens it is explicitly + * permitted to), signed by the trusted owner since storage accounts carry + * no key of their own. + */ + private static void grantHold(Keeta keeta, UserClient client, Account trusted, Account storage, Account base, + TransmitOptions trustedPays) { + try (Permissions hold = keeta.permissions(Permissions.STORAGE_CAN_HOLD); + Operation grant = keeta.modifyPermissions(base, hold, AdjustMethod.SET); + Block.UnsignedBlock unsigned = keeta.builder() + .version(2).network(client.network()).account(storage).signer(trusted) + .opening().date(System.currentTimeMillis()) + .addOperation(grant).build(); + Block.SignedBlock block = unsigned.sign()) { + client.transmit(List.of(block), trustedPays); + } + } + + /** Build and sign a send of {@code amount} base tokens atop {@code from}'s current head. */ + private static Block.SignedBlock sendBlock(Keeta keeta, UserClient client, Account from, Account to, + BigInteger amount, Account base) { + String head = client.headHash(from); + check(head != null && !head.isBlank(), "sender must have a head block"); + + try (Operation op = keeta.send(to, amount, base, ""); + Block.UnsignedBlock unsigned = keeta.builder() + .version(2).network(client.network()).account(from).signer(from) + .previous(hexDecode(head)).date(System.currentTimeMillis()) + .addOperation(op).build()) { + return unsigned.sign(); } } diff --git a/keetanetwork-client-wasi/host-tests/tests/java_fee.rs b/keetanetwork-client-wasi/host-tests/tests/java_fee.rs index f9b3fd2..fade29d 100644 --- a/keetanetwork-client-wasi/host-tests/tests/java_fee.rs +++ b/keetanetwork-client-wasi/host-tests/tests/java_fee.rs @@ -76,7 +76,15 @@ fn java_sdk_pays_a_required_fee() -> Result<(), Box> { let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); assert!(output.status.success(), "the Java harness must exit zero\nSTDOUT:\n{stdout}\nSTDERR:\n{stderr}"); + assert!( + stdout.contains("FEE_REQUIRED_OK"), + "the Java SDK must refuse a fee-less transmit with FEE_REQUIRED\nSTDOUT:\n{stdout}\nSTDERR:\n{stderr}" + ); assert!(stdout.contains("FEE_OK"), "the Java SDK must pay the required fee\nSTDOUT:\n{stdout}\nSTDERR:\n{stderr}"); + assert!( + stdout.contains("STORAGE_FEE_OK"), + "the Java SDK must pay a fee from a storage account with a delegated signer\nSTDOUT:\n{stdout}\nSTDERR:\n{stderr}" + ); Ok(()) } diff --git a/keetanetwork-client-wasi/src/p1/mod.rs b/keetanetwork-client-wasi/src/p1/mod.rs index 7dd6376..192583d 100644 --- a/keetanetwork-client-wasi/src/p1/mod.rs +++ b/keetanetwork-client-wasi/src/p1/mod.rs @@ -1198,6 +1198,49 @@ pub unsafe extern "C" fn keeta_fee_send(vote: i32, base_token: i32, priority_ptr } } +/// 1 when `vote` obliges a fee block (a required, non-optional fee schedule), +/// 0 otherwise or on a bad handle. +#[no_mangle] +pub extern "C" fn keeta_fees_required(vote: i32) -> i32 { + resolve::(vote).is_some_and(|vote| pure::fees_required(&vote)) as i32 +} + +/// The hex hash (as a bytes handle) of `payer`'s last block among the handle +/// buffer `blocks`: the chaining point for a fee block joining the round. +/// Returns 0 when the payer has no block in the round. +/// +/// # Safety +/// See [`bytes_in`]; `blocks` must be a `(ptr, len)` pair of `i32` handles. +#[no_mangle] +pub unsafe extern "C" fn keeta_blocks_tip_for(blocks_ptr: i32, blocks_len: i32, payer: i32) -> i32 { + let (Some(blocks), Some(payer)) = (resolve_handles::(blocks_ptr, blocks_len), account(payer)) else { + return 0; + }; + + match pure::blocks_tip_for(&blocks, &payer) { + Some(hash) => store_bytes(hash.into_bytes()), + None => 0, + } +} + +/// The base token account handle for `network` (the implicit fee currency); +/// 0 on failure. +#[no_mangle] +pub extern "C" fn keeta_base_token(network: i64) -> i32 { + let Ok(network) = u64::try_from(network) else { + fail(CodedError::new("INVALID_NETWORK", "network id must be non-negative")); + return 0; + }; + + match pure::base_token(network) { + Ok(token) => store_account(token), + Err(error) => { + fail(error); + 0 + } + } +} + // --------------------------------------------------------------------------- // X.509 certificate objects (handle-based) // --------------------------------------------------------------------------- diff --git a/keetanetwork-client-wasi/src/p2/mod.rs b/keetanetwork-client-wasi/src/p2/mod.rs index 85fa3d0..63ab71e 100644 --- a/keetanetwork-client-wasi/src/p2/mod.rs +++ b/keetanetwork-client-wasi/src/p2/mod.rs @@ -1160,7 +1160,7 @@ impl GuestTransaction for TransactionState { fn commit(&self) -> Result, CodedError> { let blocks = run(self.builder.borrow_mut().build())?; - let options = TransmitOptions { fee_signer: Some(Arc::clone(&self.signer)), ..Default::default() }; + let options = TransmitOptions::default().with_fee_signer(&self.signer); let accepted = run(self.client.transmit(&blocks, options))?; if !accepted { diff --git a/keetanetwork-client-wasi/src/pure.rs b/keetanetwork-client-wasi/src/pure.rs index 8e7220e..32c375c 100644 --- a/keetanetwork-client-wasi/src/pure.rs +++ b/keetanetwork-client-wasi/src/pure.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use num_bigint::BigInt; -use keetanetwork_account::KeyPairType; +use keetanetwork_account::{Account, KeyNETWORK, KeyPairType}; use keetanetwork_bindings::error::CodedError; use keetanetwork_bindings::parse::{adjust_method, base_flag, bigint_hex, purpose}; use keetanetwork_bindings::permissions as bindings_permissions; @@ -16,7 +16,7 @@ use keetanetwork_block::{ ManageCertificate, ModifyPermissions, ModifyPermissionsPrincipal, MultisigCreateArguments, Operation, Permissions, Receive, Send, SetInfo, SetRep, Signer, TokenAdminModifyBalance, TokenAdminSupply, UnsignedBlock, }; -use keetanetwork_vote::{ValidationConfig, Vote, VoteQuote, VoteStaple}; +use keetanetwork_vote::{Fees, ValidationConfig, Vote, VoteQuote, VoteStaple}; /// The account primitive operations live in the shared `keetanetwork-bindings` /// crate so every binding boundary reuses a single definition. @@ -139,6 +139,34 @@ pub fn fee_send(vote: &Vote, base_token: &AccountRef, priority: &[AccountRef]) - vote.fee_send(base_token, priority).map(Operation::from) } +/// Whether `vote` obliges a fee block: it carries a required (non-optional) +/// fee schedule. See [`Fees::required`]. +pub fn fees_required(vote: &Vote) -> bool { + vote.fees().is_some_and(Fees::required) +} + +/// The hex hash of `account`'s last block among `blocks`, if any: the +/// chaining point for a fee block joining the round. +pub fn blocks_tip_for(blocks: &[Block], account: &AccountRef) -> Option { + blocks + .iter() + .rev() + .find(|block| block.data().account() == account) + .map(block_hash) +} + +/// The base token (the `TOKEN` identifier at operation index zero of the +/// network address) for `network`: the implicit fee currency. +pub fn base_token(network: u64) -> Result { + let network_account = Account::::generate_network_address(network) + .map_err(|error| CodedError::new("IDENTIFIER", error.as_ref()))?; + let token = network_account + .generate_identifier(KeyPairType::TOKEN, None, 0) + .map_err(|error| CodedError::new("IDENTIFIER", error.as_ref()))?; + + Ok(Arc::new(token)) +} + /// The staple hash as a hex string. pub fn staple_hash(staple: &VoteStaple) -> String { staple.hash().to_string() @@ -379,6 +407,77 @@ fn decode_certificate_der(certificate: &str) -> Result Block { + let date = block_time(1_700_000_000_000).expect("timestamp must be in range"); + let builder = BlockBuilder::default() + .with_network(0u64) + .with_account(user.clone()) + .with_signer(signer_single(user.clone())) + .with_date(date) + .as_opening() + .with_operation(op_set_rep(rep)); + + let unsigned = build_unsigned(builder).expect("the unsigned block must build"); + sign_unsigned(unsigned).expect("signing must succeed") + } + + /// A signed vote over one block hash, optionally carrying `fees`. + fn signed_vote(issuer: &AccountRef, fees: Option) -> Vote { + let from = block_time(1_700_000_000_000).expect("timestamp must be in range"); + let to = block_time(1_700_000_600_000).expect("timestamp must be in range"); + let mut builder = VoteBuilder::new() + .serial(1u8) + .issuer(Arc::clone(issuer)) + .validity(from, to) + .add_block(BlockHash::from([7u8; 32])); + + if let Some(fees) = fees { + builder = builder.fees(fees); + } + + builder + .build_signed(issuer.as_ref()) + .expect("the vote must sign") + } + + #[test] + fn blocks_tip_for_finds_the_accounts_last_block() { + let seed = generate_seed().expect("seed generation must succeed"); + let user = account_from_seed(&seed, 0, DEFAULT_ALGORITHM).expect("derivation must succeed"); + let rep = account_from_seed(&seed, 1, DEFAULT_ALGORITHM).expect("derivation must succeed"); + let outsider = account_from_seed(&seed, 2, DEFAULT_ALGORITHM).expect("derivation must succeed"); + + let block = signed_block(&user, rep); + let blocks = [block.clone()]; + assert_eq!(blocks_tip_for(&blocks, &user), Some(block_hash(&block))); + assert_eq!(blocks_tip_for(&blocks, &outsider), None); + } + + #[test] + fn fees_required_only_for_a_nonzero_schedule_with_no_zero_option() { + let seed = generate_seed().expect("seed generation must succeed"); + let issuer = account_from_seed(&seed, 0, DEFAULT_ALGORITHM).expect("derivation must succeed"); + + let fee = |amount: u64| Fee { amount: Amount::from(amount), pay_to: None, token: None }; + let required = Fees::from_entries(false, vec![fee(10)]).expect("fees must build"); + let optional = Fees::from_entries(false, vec![fee(10), fee(0)]).expect("fees must build"); + assert!(!fees_required(&signed_vote(&issuer, None))); + assert!(fees_required(&signed_vote(&issuer, Some(required)))); + assert!(!fees_required(&signed_vote(&issuer, Some(optional)))); + } + + #[test] + fn base_token_is_deterministic_per_network() { + let first = base_token(1).expect("the base token must derive"); + let again = base_token(1).expect("the base token must derive"); + let other = base_token(2).expect("the base token must derive"); + assert_eq!(first.to_string(), again.to_string()); + assert_ne!(first.to_string(), other.to_string()); + } + #[test] fn permissions_round_trip_through_bitmaps() { let flags = [String::from("admin")]; diff --git a/keetanetwork-client-wasm/src/options.rs b/keetanetwork-client-wasm/src/options.rs index d6f8c6b..d26e5e3 100644 --- a/keetanetwork-client-wasm/src/options.rs +++ b/keetanetwork-client-wasm/src/options.rs @@ -1,5 +1,7 @@ //! JS `TransmitOptions`: publish-time controls passed to publish/transmit. +use core::mem; + use keetanetwork_client::TransmitOptions as Core; use wasm_bindgen::prelude::wasm_bindgen; @@ -22,11 +24,11 @@ impl TransmitOptions { Self::default() } - /// Account that originates and signs a fee block when the votes require - /// one. Without it, a required fee fails with `FEE_REQUIRED`. + /// Account that pays and signs a fee block when the votes require one. + /// Without it, a required fee fails with `FEE_REQUIRED`. #[wasm_bindgen(js_name = setFeeSigner)] pub fn set_fee_signer(&mut self, signer: &Account) { - self.inner.fee_signer = Some(signer.inner()); + self.inner = mem::take(&mut self.inner).with_fee_signer(&signer.inner()); } /// Append a token to the fee-token preference order, highest priority diff --git a/keetanetwork-client-wasm/src/user.rs b/keetanetwork-client-wasm/src/user.rs index fbea9b5..7a33385 100644 --- a/keetanetwork-client-wasm/src/user.rs +++ b/keetanetwork-client-wasm/src/user.rs @@ -307,10 +307,14 @@ impl UserClient { None => self.inner.recover(publish).await, Some(options) => { let account = self.inner.account().map_err(client_error)?; + let mut core = options.to_core(); - if core.fee_signer.is_none() { - core.fee_signer = self.inner.signer_account().cloned(); + if core.generate_fee_block.is_none() { + if let Some(signer) = self.inner.signer_account() { + core = core.with_fee_signer(signer); + } } + self.inner .client() .recover_account(&account, publish, core) diff --git a/keetanetwork-client/src/client.rs b/keetanetwork-client/src/client.rs index 92887bb..432b16d 100644 --- a/keetanetwork-client/src/client.rs +++ b/keetanetwork-client/src/client.rs @@ -51,6 +51,15 @@ struct RefreshState { handle: Option>, } +/// The identity paying a fee block: the `account` whose balance pays and the +/// `signer` sealing it (distinct under delegated signing, e.g. a storage +/// account whose owner signs). +#[derive(Clone, Copy)] +struct FeePayer<'a> { + account: &'a AccountRef, + signer: &'a AccountRef, +} + /// A selection target bound to its live transport: the scoring core yields a /// [`RepRef`] (key + weight) which the client joins against its transport /// registry to produce this. @@ -786,7 +795,7 @@ impl KeetaClient { /// /// - [`ClientError::NoRepresentatives`] -- no representative is configured to vote /// - [`ClientError::QuorumNotReached`] -- the returned votes did not reach quorum weight - /// - [`ClientError::FeeRequired`] -- the node requires a fee but no `fee_signer` was supplied + /// - [`ClientError::FeeRequired`] -- the node requires a fee but no `generate_fee_block` was supplied /// - [`ClientError::Node`] -- a representative rejected the blocks or staple pub async fn transmit(&self, blocks: &[Block], options: TransmitOptions) -> Result { self.transmit_with_optional_fee(blocks, &options).await @@ -796,7 +805,7 @@ impl KeetaClient { /// retried on insufficient voting weight. /// /// After the temporary round, if the votes require a fee (a non-zero fee - /// with no zero-amount option), `options.fee_signer` originates a + /// with no zero-amount option), `options.generate_fee_block` supplies a /// [`BlockPurpose::Fee`] block that joins the permanent round and staple. async fn transmit_with_optional_fee( &self, @@ -834,12 +843,8 @@ impl KeetaClient { let mut all = blocks.to_vec(); if fees_required(&temporary) { - let signer = options - .fee_signer - .as_ref() - .ok_or(ClientError::FeeRequired)?; let fee_block = self - .build_fee_block(signer, blocks, &temporary, moment, &options.fee_token_priority) + .fee_block_from_options(blocks, &temporary, moment, options) .await?; all.push(fee_block); } @@ -929,15 +934,15 @@ impl KeetaClient { /// pending successor block from the votes scattered across reps, fetching /// the voted-on blocks, topping up votes, and republishing. /// - /// `options.fee_signer` originates a fee block if the recovered votes - /// require a fee and no permanent votes exist yet. Returns the recovered - /// staple, or `None` when there is nothing pending to recover. + /// `options.generate_fee_block` supplies a fee block if the recovered + /// votes require a fee and no permanent votes exist yet. Returns the + /// recovered staple, or `None` when there is nothing pending to recover. /// /// # Errors /// /// - [`ClientError::NoRepresentatives`] -- no representative is configured to query /// - [`ClientError::RecoverFailed`] -- the pending votes or blocks could not be reassembled - /// - [`ClientError::FeeRequired`] -- a fee is required but `options.fee_signer` is absent + /// - [`ClientError::FeeRequired`] -- a fee is required but `options.generate_fee_block` is absent /// - [`ClientError::Node`] -- a representative rejected a fetch or publish request pub async fn recover_account( &self, @@ -1068,12 +1073,8 @@ impl KeetaClient { } if votes.perm_votes.is_empty() && fees_required(&votes.temp_votes) { - let signer = options - .fee_signer - .as_ref() - .ok_or(ClientError::FeeRequired)?; let fee_block = self - .build_fee_block(signer, blocks, &votes.temp_votes, moment, &options.fee_token_priority) + .fee_block_from_options(blocks, &votes.temp_votes, moment, options) .await?; blocks.push(fee_block); } @@ -1183,34 +1184,86 @@ impl KeetaClient { } /// Build and sign a [`BlockPurpose::Fee`] block paying the fees declared - /// by `votes`, chained after `signer`'s block in `blocks`. - async fn build_fee_block( + /// by the temporary-round `staple`'s votes. + /// + /// `account` originates the block (its balance pays) and `signer` signs + /// it. Pass the same account twice unless signing is delegated (e.g. a + /// storage account whose owner signs). + /// + /// `previous` chains after `account`'s tip within the staple when + /// present, otherwise the ledger head, so the payer need not already + /// appear in the staple. + /// + /// # Errors + /// + /// - [`ClientError::FeeRequired`] -- the votes carry no payable fee entry + /// - [`ClientError::Block`] -- the fee block could not be assembled or signed + pub async fn build_fee_block( &self, + staple: &VoteStaple, + account: &AccountRef, signer: &AccountRef, + priority: &[AccountRef], + ) -> Result { + let payer = FeePayer { account, signer }; + self.fee_block_for(payer, staple.blocks(), staple.votes(), self.now_moment(), priority) + .await + } + + /// [`build_fee_block`](Self::build_fee_block) over raw slices with an + /// explicit date, used mid-transmit where the staple is not yet assembled + /// and every block must share the round's `moment`. + async fn fee_block_for( + &self, + payer: FeePayer<'_>, blocks: &[Block], votes: &[Vote], moment: BlockTime, priority: &[AccountRef], ) -> Result { - let previous = blocks + let staple_tip = blocks .iter() .rev() - .find(|block| block.data().account() == signer) - .map(|block| block.hash()) - .ok_or(ClientError::FeeRequired)?; + .find(|block| block.data().account() == payer.account) + .map(|block| block.hash()); - let mut builder = self.builder(signer); - builder - .with_purpose(BlockPurpose::Fee) - .with_previous(previous) - .with_date(moment); + let mut builder = self.builder(payer.account); + if payer.signer != payer.account { + builder.for_account_with_signer(payer.account, payer.signer); + } + + builder.with_purpose(BlockPurpose::Fee).with_date(moment); + + if let Some(previous) = staple_tip { + builder.with_previous(previous); + } for operation in self.fee_operations(votes, priority)? { builder.with_operation(operation); } - let mut blocks = builder.build().await?; - blocks.pop().ok_or(ClientError::FeeRequired) + let mut fee_blocks = builder.build().await?; + fee_blocks.pop().ok_or(ClientError::FeeRequired) + } + + /// Resolve the fee block for a round whose votes require one by invoking + /// `options.generate_fee_block` with the temporary-round staple, like the + /// reference implementation's `generateFeeBlock`. + async fn fee_block_from_options( + &self, + blocks: &[Block], + votes: &[Vote], + moment: BlockTime, + options: &TransmitOptions, + ) -> Result { + let generate = options + .generate_fee_block + .as_ref() + .ok_or(ClientError::FeeRequired)?; + + let config = ValidationConfig::default(); + let staple = VoteStaple::try_new(blocks.to_vec(), votes.to_vec(), config, moment).context(VoteSnafu)?; + generate(self.clone(), staple, options.fee_token_priority.clone()).await } /// Translate the fee schedule carried by `votes` into the `SEND` @@ -1244,13 +1297,13 @@ impl KeetaClient { /// Publish a single block built via [`builder`](Self::builder). /// - /// `options.fee_signer` pays a fee when the node requires one (absent, a - /// required fee fails with [`ClientError::FeeRequired`]). + /// `options.generate_fee_block` pays a fee when the node requires one + /// (absent, a required fee fails with [`ClientError::FeeRequired`]). /// /// # Errors /// /// - [`ClientError::NoRepresentatives`] -- no representative is configured to vote - /// - [`ClientError::FeeRequired`] -- the node requires a fee but no `fee_signer` was supplied + /// - [`ClientError::FeeRequired`] -- the node requires a fee but no `generate_fee_block` was supplied /// - [`ClientError::QuorumNotReached`] -- the returned votes did not reach quorum weight /// - [`ClientError::Node`] -- a representative rejected the block or staple pub async fn publish(&self, block: Block, options: TransmitOptions) -> Result { @@ -1280,7 +1333,7 @@ impl KeetaClient { builder.send(to, token, amount); let blocks = builder.build().await?; - let options = TransmitOptions { fee_signer: Some(Arc::clone(from)), ..Default::default() }; + let options = TransmitOptions::default().with_fee_signer(from); let mut accepted = true; for block in blocks { accepted &= self.publish(block, options.clone()).await?; diff --git a/keetanetwork-client/src/lib.rs b/keetanetwork-client/src/lib.rs index a5b44a6..2af1ef9 100644 --- a/keetanetwork-client/src/lib.rs +++ b/keetanetwork-client/src/lib.rs @@ -105,8 +105,8 @@ pub use keetanetwork_vote::{Vote, VoteBlockHash, VoteQuote, VoteStaple}; pub use marker::{MaybeSend, MaybeSync}; pub use model::{ AccountInfo, AccountOrPending, AccountState, Acl, AclPrincipal, BlockEffects, Certificate, ChainPage, ChainQuery, - HistoryEntry, HistoryPage, HistoryQuery, LedgerChecksum, PendingAccount, Representative, TokenBalance, - TransmitOptions, + FeeBlockFuture, GenerateFeeBlock, HistoryEntry, HistoryPage, HistoryQuery, LedgerChecksum, PendingAccount, + Representative, TokenBalance, TransmitOptions, }; pub use rep::RepPart; pub use runtime::{BoxFuture, Runtime, TaskHandle}; diff --git a/keetanetwork-client/src/model.rs b/keetanetwork-client/src/model.rs index fd4b8c4..32accad 100644 --- a/keetanetwork-client/src/model.rs +++ b/keetanetwork-client/src/model.rs @@ -1,13 +1,18 @@ //! Domain-typed request/response models exposed by //! [`KeetaClient`](crate::KeetaClient). +use alloc::boxed::Box; use alloc::string::String; use alloc::sync::Arc; use alloc::vec::Vec; +use core::fmt; +use core::future::Future; +use core::pin::Pin; use keetanetwork_block::{AccountRef, Amount, Block, BlockHash, BlockTime, Operation, Permissions}; use keetanetwork_vote::{VoteBlockHash, VoteQuote, VoteStaple}; +use crate::client::KeetaClient; use crate::error::ClientError; use crate::sync::Once; @@ -265,6 +270,33 @@ pub struct AccountState { pub balances: Vec, } +/// The future a [`GenerateFeeBlock`] callback returns. The `Send` bound is +/// required on native targets and dropped on wasm, where futures are single-threaded. +#[cfg(not(target_family = "wasm"))] +pub type FeeBlockFuture = Pin> + Send>>; + +/// The future a [`GenerateFeeBlock`] callback returns. +#[cfg(target_family = "wasm")] +pub type FeeBlockFuture = Pin>>>; + +/// Caller-supplied fee-block factory, invoked mid-transmit with the +/// temporary-round staple and the round's +/// [`fee_token_priority`](TransmitOptions::fee_token_priority) when the +/// representatives' votes require a fee. The returned block joins the +/// permanent round and the published staple. +/// +/// Receives a clone of the transmitting client so the callback can chain +/// through [`KeetaClient::build_fee_block`] without capturing one. +#[cfg(not(target_family = "wasm"))] +pub type GenerateFeeBlock = Arc) -> FeeBlockFuture + Send + Sync>; + +/// Caller-supplied fee-block factory, invoked mid-transmit with the +/// temporary-round staple and the round's +/// [`fee_token_priority`](TransmitOptions::fee_token_priority) when the +/// representatives' votes require a fee. +#[cfg(target_family = "wasm")] +pub type GenerateFeeBlock = Arc) -> FeeBlockFuture>; + /// Optional inputs to [`KeetaClient::transmit`](crate::KeetaClient::transmit). /// /// Constructed with [`Default`] and overridden field-by-field: @@ -273,24 +305,68 @@ pub struct AccountState { /// use keetanetwork_client::TransmitOptions; /// /// let options = TransmitOptions::default(); -/// assert!(options.fee_signer.is_none()); +/// assert!(options.generate_fee_block.is_none()); /// assert!(options.quotes.is_empty()); /// ``` -#[derive(Clone, Debug, Default)] +#[derive(Clone, Default)] pub struct TransmitOptions { - /// Account that originates and signs a - /// [`BlockPurpose::Fee`](keetanetwork_block::BlockPurpose::Fee) block when - /// the representatives' votes require a fee. Absent, a required fee fails - /// with [`ClientError::FeeRequired`]. - pub fee_signer: Option, /// Pre-fetched vote quotes to attach to the temporary round. Each quote is /// routed to the representative that issued it. pub quotes: Vec, /// Tokens to prefer when a fee entry is payable in several tokens, ranked /// highest priority first. An entry with an implicit (`None`) token counts /// as the network base token. Empty (the default) prefers the base-token - /// entry, then the first entry. + /// entry, then the first entry. Handed to + /// [`generate_fee_block`](Self::generate_fee_block) at invocation, so it + /// may be set before or after the factory. pub fee_token_priority: Vec, + /// Fee-block factory invoked when the representatives' votes require a + /// fee. Absent, a required fee fails with [`ClientError::FeeRequired`]. + /// See [`with_fee_signer`](Self::with_fee_signer) and + /// [`with_fee_block_from`](Self::with_fee_block_from) for the common + /// shapes; write the closure by hand only for exotic payment flows. + pub generate_fee_block: Option, +} + +impl TransmitOptions { + /// Set [`generate_fee_block`](Self::generate_fee_block) to pay any + /// required fee from `signer`, signing for itself. + pub fn with_fee_signer(self, signer: &AccountRef) -> Self { + self.with_fee_block_from(signer, signer) + } + + /// Set [`generate_fee_block`](Self::generate_fee_block) to pay any + /// required fee from `account`, signed by `signer` (delegated signing, + /// e.g. a storage account whose owner signs). For a payer that signs for + /// itself, prefer [`with_fee_signer`](Self::with_fee_signer). + pub fn with_fee_block_from(mut self, account: &AccountRef, signer: &AccountRef) -> Self { + let account = Arc::clone(account); + let signer = Arc::clone(signer); + + self.generate_fee_block = Some(Arc::new(move |client, staple, priority| { + let account = Arc::clone(&account); + let signer = Arc::clone(&signer); + + Box::pin(async move { + client + .build_fee_block(&staple, &account, &signer, &priority) + .await + }) + })); + + self + } +} + +impl fmt::Debug for TransmitOptions { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("TransmitOptions") + .field("quotes", &self.quotes) + .field("fee_token_priority", &self.fee_token_priority) + .field("generate_fee_block", &self.generate_fee_block.is_some()) + .finish() + } } /// Liveness and statistics for a single representative, as gathered by diff --git a/keetanetwork-client/src/user.rs b/keetanetwork-client/src/user.rs index 60fe4cb..83e31b7 100644 --- a/keetanetwork-client/src/user.rs +++ b/keetanetwork-client/src/user.rs @@ -255,7 +255,11 @@ impl UserClient { /// - [`ClientError::Node`] -- recovery failed at the node. pub async fn recover(&self, publish: bool) -> Result, ClientError> { let account = self.account_or(None)?; - let options = TransmitOptions { fee_signer: self.signer.clone(), ..Default::default() }; + let mut options = TransmitOptions::default(); + if let Some(signer) = &self.signer { + options = options.with_fee_signer(signer); + } + self.client .recover_account(&account, publish, options) .await @@ -375,7 +379,7 @@ impl UserClient { /// - [`ClientError::Node`] -- the node rejected the staple. pub async fn publish(&self, block: Block, options: TransmitOptions) -> Result { self.client - .publish(block, self.with_fee_signer(options)?) + .publish(block, self.or_default_fee_payer(options)?) .await } @@ -389,7 +393,7 @@ impl UserClient { /// - [`ClientError::Node`] -- the node rejected the staple. pub async fn transmit(&self, blocks: &[Block], options: TransmitOptions) -> Result { self.client - .transmit(blocks, self.with_fee_signer(options)?) + .transmit(blocks, self.or_default_fee_payer(options)?) .await } @@ -639,14 +643,16 @@ impl UserClient { Ok(blocks) } - /// Default the fee signer to the bound signer when the caller left it - /// unset. - fn with_fee_signer(&self, mut options: TransmitOptions) -> Result { - if options.fee_signer.is_none() { - options.fee_signer = Some(self.signer()?); + /// Keep the caller's fee-block factory when supplied, otherwise fall back + /// to the bound signer paying for itself, mirroring the reference + /// implementation's `UserClient` default `generateFeeBlock`. + fn or_default_fee_payer(&self, options: TransmitOptions) -> Result { + if options.generate_fee_block.is_some() { + return Ok(options); } - Ok(options) + let signer = self.signer()?; + Ok(options.with_fee_signer(&signer)) } /// Build the operating account's block(s) from `assemble`, then publish. @@ -687,7 +693,7 @@ impl UserClient { /// stops the run. async fn originate(&self, blocks: Vec) -> Result { let signer = self.signer()?; - let options = TransmitOptions { fee_signer: Some(signer), ..Default::default() }; + let options = TransmitOptions::default().with_fee_signer(&signer); let mut accepted = true; for block in blocks { accepted &= self.client.publish(block, options.clone()).await?; diff --git a/keetanetwork-client/tests/e2e.rs b/keetanetwork-client/tests/e2e.rs index 4bed766..71e046e 100644 --- a/keetanetwork-client/tests/e2e.rs +++ b/keetanetwork-client/tests/e2e.rs @@ -11,7 +11,10 @@ use std::sync::Arc; use keetanetwork_account::{AccountPublicKey, GenericAccount, KeyPairType}; use keetanetwork_block::testing::generate_ed25519_ref; -use keetanetwork_block::{AccountRef, AdjustMethod, Amount, Block, BlockHash, BlockTime, Hashable, Operation, SetInfo}; +use keetanetwork_block::{ + AccountRef, AdjustMethod, Amount, BaseFlag, Block, BlockHash, BlockTime, Hashable, ModifyPermissions, + ModifyPermissionsPrincipal, Operation, Permissions, SetInfo, +}; use keetanetwork_client::{ AcceptSwapRequest, ChainQuery, ClientConfig, ClientError, CreateSwapRequest, HistoryQuery, InitializeNetwork, KeetaClient, KeetaNetError, LedgerSide, Network, NodeErrorType, RepEndpoint, TransactionBuilder, TransmitOptions, @@ -584,6 +587,168 @@ async fn test_transmit_without_signer_when_fee_required_errors() -> Result<(), B Ok(()) } +/// Seed byte for the third-party fee payer in the fee-payer tests. Each test +/// boots its own node, so the seed is shared safely across them. +const FEE_PAYER_SEED_BYTE: u8 = 0x44; + +/// Seed byte for the send recipient in the fee-payer tests. Distinct from +/// the representative so the fee `payTo` credit is not mixed. +const FEE_RECIPIENT_SEED_BYTE: u8 = 0x45; + +/// Funding granted to a fee payer, covering several node fees. +const PAYER_FUNDING: u64 = FEE_AMOUNT * 10; + +/// Publish-time options letting the trusted account pay the fees the setup +/// transmits themselves incur on a fee-enforcing node. +fn trusted_fee_options(accounts: &SigningAccounts) -> TransmitOptions { + TransmitOptions::default().with_fee_signer(&accounts.trusted) +} + +/// Fund a fresh ed25519 fee payer from the trusted account. +async fn funded_payer( + client: &KeetaClient, + accounts: &SigningAccounts, +) -> Result> { + let payer = generate_ed25519_ref(FEE_PAYER_SEED_BYTE); + + let amount = Amount::from(PAYER_FUNDING); + let funded = client + .send(&accounts.trusted, &payer, &accounts.token, amount) + .await?; + assert!(funded, "funding the fee payer must be accepted"); + + Ok(payer) +} + +/// Create a storage account able to pay fees: derive the identifier under the +/// trusted account, grant it `STORAGE_CAN_HOLD` for the base token, and fund it. +async fn funded_storage_payer( + client: &KeetaClient, + accounts: &SigningAccounts, +) -> Result> { + let mut create_builder = client.builder(&accounts.trusted); + let pending = create_builder.generate_identifier(KeyPairType::STORAGE, None); + let create_blocks = create_builder.build().await?; + let fee_options = trusted_fee_options(accounts); + + let created = client.transmit(&create_blocks, fee_options).await?; + assert!(created, "the create-storage block must be accepted"); + + let storage = pending.get()?; + let hold_permissions = Permissions::from_flags(&[BaseFlag::StorageCanHold], &[])?; + let mut grant_builder = client.builder(&accounts.trusted); + grant_builder.for_account_with_signer(&storage, &accounts.trusted); + grant_builder.modify_permissions(ModifyPermissions { + principal: ModifyPermissionsPrincipal::Account(Arc::clone(&accounts.token)), + method: AdjustMethod::Set, + permissions: Some(hold_permissions), + target: None, + }); + let grant_blocks = grant_builder.build().await?; + + let fee_options = trusted_fee_options(accounts); + let granted = client.transmit(&grant_blocks, fee_options).await?; + assert!(granted, "the STORAGE_CAN_HOLD grant must be accepted"); + + let amount = Amount::from(PAYER_FUNDING); + let funded = client + .send(&accounts.trusted, &storage, &accounts.token, amount) + .await?; + assert!(funded, "funding the storage payer must be accepted"); + + Ok(storage) +} + +/// Shared assertion for the third-party fee-payer probes: transmit a send +/// from the trusted account under `options`, then assert the sender was +/// debited only the send amount, `payer` exactly the fee, and the recipient +/// credited the send amount. +async fn assert_third_party_pays_fee( + client: &KeetaClient, + accounts: &SigningAccounts, + payer: &AccountRef, + options: TransmitOptions, +) -> Result<(), Box> { + let recipient = generate_ed25519_ref(FEE_RECIPIENT_SEED_BYTE); + + let sender_before = client.balance(&*accounts.trusted, &*accounts.token).await?; + let payer_before = client.balance(&**payer, &*accounts.token).await?; + + let block = send_block(client, accounts, &recipient, SEND_AMOUNT).await?; + let accepted = client.transmit(&[block], options).await?; + assert!(accepted, "the node must accept a staple whose fee block a third party originated"); + + let sender_after = client.balance(&*accounts.trusted, &*accounts.token).await?; + let payer_after = client.balance(&**payer, &*accounts.token).await?; + let recipient_balance = client.balance(&*recipient, &*accounts.token).await?; + + let sender_debit = sender_before.as_bigint() - sender_after.as_bigint(); + let payer_debit = payer_before.as_bigint() - payer_after.as_bigint(); + + assert_eq!( + sender_debit, + BigInt::from(SEND_AMOUNT), + "the sender must be debited only the send amount when a third party pays the fee" + ); + assert_eq!(payer_debit, BigInt::from(FEE_AMOUNT), "the fee payer must be debited exactly the required fee"); + assert_eq!( + recipient_balance.as_bigint(), + &BigInt::from(SEND_AMOUNT), + "the recipient must be credited the send amount" + ); + + Ok(()) +} + +/// The `with_fee_signer` helper must pay a required fee from a third party +/// that does not appear in the staple, without any hand-written closure, +/// exercising the ledger-head `previous` fallback. +#[tokio::test(flavor = "multi_thread")] +async fn test_with_fee_signer_third_party_pays() -> Result<(), Box> { + let (_node, client, accounts) = fee_fixture(); + let payer = funded_payer(&client, &accounts).await?; + + let options = TransmitOptions::default().with_fee_signer(&payer); + assert_third_party_pays_fee(&client, &accounts, &payer, options).await +} + +/// A hand-written `generate_fee_block` closure calling the public +/// `build_fee_block` with the temporary-round staple must pay a required fee +/// from a third party, matching the reference implementation's +/// `generateFeeBlock` + `computeFeeBlock` pair. +#[tokio::test(flavor = "multi_thread")] +async fn test_build_fee_block_via_callback_third_party_pays() -> Result<(), Box> { + let (_node, client, accounts) = fee_fixture(); + let payer = funded_payer(&client, &accounts).await?; + + let closure_payer = Arc::clone(&payer); + let options = TransmitOptions { + generate_fee_block: Some(Arc::new(move |client, staple, priority| { + let payer = Arc::clone(&closure_payer); + Box::pin(async move { + client + .build_fee_block(&staple, &payer, &payer, &priority) + .await + }) + })), + ..Default::default() + }; + + assert_third_party_pays_fee(&client, &accounts, &payer, options).await +} + +/// A storage account (no key of its own) must pay a required fee with its +/// owner signing the fee block, exercising the delegated `account`/`signer` +/// split of `build_fee_block`. +#[tokio::test(flavor = "multi_thread")] +async fn test_with_fee_block_from_storage_account_pays() -> Result<(), Box> { + let (_node, client, accounts) = fee_fixture(); + let storage = funded_storage_payer(&client, &accounts).await?; + + let options = TransmitOptions::default().with_fee_block_from(&storage, &accounts.trusted); + assert_third_party_pays_fee(&client, &accounts, &storage, options).await +} + /// Number of peered representatives the multi-rep cluster boots. const CLUSTER_REPS: usize = 2; From 841476611167d46d8c0b3fffe2277cc0913b61e6 Mon Sep 17 00:00:00 2001 From: Tanveer Wahid Date: Thu, 23 Jul 2026 14:50:51 -0700 Subject: [PATCH 2/8] fix(wasi): better reference alignment --- .../java/network/keeta/wasi/FeeRound.java | 35 ----- .../network/keeta/wasi/GenerateFeeBlock.java | 15 +- .../network/keeta/wasi/TransmitOptions.java | 2 +- .../java/network/keeta/wasi/UserClient.java | 132 ++++++++++++------ .../java/network/keeta/wasi/VoteStaple.java | 26 ++++ .../keeta/wasi/harness/FeeTransfer.java | 5 +- keetanetwork-client-wasi/src/p1/mod.rs | 83 ++++++++--- keetanetwork-client-wasi/src/pure.rs | 101 ++++++++++---- 8 files changed, 259 insertions(+), 140 deletions(-) delete mode 100644 keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/FeeRound.java create mode 100644 keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/VoteStaple.java diff --git a/keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/FeeRound.java b/keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/FeeRound.java deleted file mode 100644 index 72252b1..0000000 --- a/keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/FeeRound.java +++ /dev/null @@ -1,35 +0,0 @@ -package network.keeta.wasi; - -import java.util.List; - -/** - * The temporary-round context handed to a {@link GenerateFeeBlock} factory: - * the blocks being published, the node's temporary vote declaring the fee, - * and the caller's fee-token preferences. - */ -public final class FeeRound { - private final List blocks; - private final String temporaryVoteBase64; - private final List feeTokenPriority; - - FeeRound(List blocks, String temporaryVoteBase64, List feeTokenPriority) { - this.blocks = blocks; - this.temporaryVoteBase64 = temporaryVoteBase64; - this.feeTokenPriority = feeTokenPriority; - } - - /** The blocks of the temporary round the fee block will join. */ - public List blocks() { - return blocks; - } - - /** The node's temporary vote (base64) declaring the required fee. */ - public String temporaryVoteBase64() { - return temporaryVoteBase64; - } - - /** Preferred fee tokens, highest priority first; may be empty. */ - public List feeTokenPriority() { - return feeTokenPriority; - } -} diff --git a/keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/GenerateFeeBlock.java b/keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/GenerateFeeBlock.java index b4e40e6..e419d50 100644 --- a/keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/GenerateFeeBlock.java +++ b/keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/GenerateFeeBlock.java @@ -1,13 +1,16 @@ package network.keeta.wasi; +import java.util.List; + /** - * Caller-supplied fee-block factory, invoked mid-transmit with the temporary - * round; the returned block joins the permanent round and the staple. - * Receives the transmitting client so it can chain through - * {@link UserClient#buildFeeBlock(FeeRound, Account, Account)}. Return - * {@code null} to publish without a fee block (no fee owed). + * Factory invoked mid-transmit when {@link TransmitOptions} carries one: build + * and sign the block paying the fee the temporary-round {@code staple} + * demands, honoring the {@code feeTokenPriority} preference. Return + * {@code null} to pay nothing. See + * {@link UserClient#buildFeeBlock(VoteStaple, Account, Account, List)} for the + * common implementation. */ @FunctionalInterface public interface GenerateFeeBlock { - Block.SignedBlock generate(UserClient client, FeeRound round); + Block.SignedBlock generate(UserClient client, VoteStaple staple, List feeTokenPriority); } diff --git a/keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/TransmitOptions.java b/keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/TransmitOptions.java index 91c7782..8f5a0e1 100644 --- a/keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/TransmitOptions.java +++ b/keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/TransmitOptions.java @@ -42,7 +42,7 @@ public TransmitOptions withFeeSigner(Account signer) { * payer that signs for itself, prefer {@link #withFeeSigner(Account)}. */ public TransmitOptions withFeeBlockFrom(Account account, Account signer) { - this.generateFeeBlock = (client, round) -> client.buildFeeBlock(round, account, signer); + this.generateFeeBlock = (client, staple, priority) -> client.buildFeeBlock(staple, account, signer, priority); return this; } diff --git a/keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/UserClient.java b/keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/UserClient.java index 02f8474..1101c4c 100644 --- a/keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/UserClient.java +++ b/keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/UserClient.java @@ -1,6 +1,8 @@ package network.keeta.wasi; import java.math.BigInteger; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; import java.util.ArrayList; import java.util.Base64; import java.util.List; @@ -115,7 +117,9 @@ public void transmit(List blocks, TransmitOptions options) { Block.SignedBlock feeBlock = null; if (factory != null) { - feeBlock = factory.generate(this, new FeeRound(blocks, temporary, options.feeTokenPriority())); + try (VoteStaple staple = stapleFor(blocks, temporary)) { + feeBlock = factory.generate(this, staple, options.feeTokenPriority()); + } } try { @@ -150,19 +154,12 @@ private static List encode(List blocks) { /** Assemble the staple over {@code blocks} plus the permanent vote, and post it. */ private void publishStaple(List blocks, String permanentVoteBase64) { - byte[] voteBytes = Base64.getDecoder().decode(permanentVoteBase64); - int votePtr = net.write(voteBytes); - int voteHandle = net.handle("keeta_vote_from_bytes", votePtr, voteBytes.length); + int voteHandle = voteHandle(permanentVoteBase64); try { - int[] blockHandles = new int[blocks.size()]; - for (int index = 0; index < blockHandles.length; index++) { - blockHandles[index] = blocks.get(index).handle(); - } - - int blocksPtr = net.writeHandles(blockHandles); + int blocksPtr = net.writeHandles(blockHandles(blocks)); int votesPtr = net.writeHandles(voteHandle); long currentTime = System.currentTimeMillis(); - int stapleHandle = net.handle("keeta_vote_staple_build", blocksPtr, blockHandles.length * 4, votesPtr, 4, currentTime); + int stapleHandle = net.handle("keeta_vote_staple_build", blocksPtr, blocks.size() * 4, votesPtr, 4, currentTime); byte[] staple = net.takeBytes(stapleHandle); String stapleBase64 = Base64.getEncoder().encodeToString(staple); @@ -173,37 +170,66 @@ private void publishStaple(List blocks, String permanentVoteB } /** - * Build and sign a fee block paying {@code round}'s required fee: - * {@code account}'s balance pays, {@code signer} signs. + * Assemble a validated {@link VoteStaple} over {@code blocks} and the + * base64 vote endorsing them, enforcing the staple invariants now. */ - public Block.SignedBlock buildFeeBlock(FeeRound round, Account account, Account signer) { - int voteHandle = voteHandle(round.temporaryVoteBase64()); + private VoteStaple stapleFor(List blocks, String voteBase64) { + int voteHandle = voteHandle(voteBase64); try { - int feeOpHandle = feeSend(voteHandle, round.feeTokenPriority()); - if (feeOpHandle == 0) { - return null; - } + int blocksPtr = net.writeHandles(blockHandles(blocks)); + int votesPtr = net.writeHandles(voteHandle); + long currentTime = System.currentTimeMillis(); + int staple = net.handle("keeta_vote_staple_new", blocksPtr, blocks.size() * 4, votesPtr, 4, currentTime); + + return new VoteStaple(net, staple); + } finally { + net.free("keeta_vote_free", voteHandle); + } + } + + /** + * Build and sign a fee block paying the fee {@code staple}'s votes + * require: {@code account}'s balance pays, {@code signer} signs (distinct + * under delegated signing, e.g. a storage account whose owner signs). + * Chains atop {@code account}'s block in the staple, else its ledger + * head, so the payer need not appear in the round. Returns {@code null} + * when no fee is owed. + */ + public Block.SignedBlock buildFeeBlock(VoteStaple staple, Account account, Account signer, List priority) { + int[] feeOpHandles = stapleFeeSends(staple, priority); + if (feeOpHandles.length == 0) { + return null; + } - String previous = tipHashFor(account, round.blocks()); - if (previous == null) { - previous = headHash(account); + String previous = tipHashFor(staple, account); + if (previous == null) { + previous = headHash(account); + } + + Block.Builder builder = keeta.builder() + .version(2) + .network(network) + .account(account) + .signer(signer) + .purpose("fee") + .date(System.currentTimeMillis()); + Block.Builder positioned = positionAfter(builder, previous); + + List feeOps = new ArrayList<>(feeOpHandles.length); + try { + for (int handle : feeOpHandles) { + Operation feeOp = new Operation(net, handle); + feeOps.add(feeOp); + positioned = positioned.addOperation(feeOp); } - Block.Builder builder = keeta.builder() - .version(2) - .network(network) - .account(account) - .signer(signer) - .purpose("fee") - .date(System.currentTimeMillis()); - Block.Builder positioned = positionAfter(builder, previous); - - try (Operation feeOp = new Operation(net, feeOpHandle); - Block.UnsignedBlock unsigned = positioned.addOperation(feeOp).build()) { + try (Block.UnsignedBlock unsigned = positioned.build()) { return unsigned.sign(); } } finally { - net.free("keeta_vote_free", voteHandle); + for (Operation feeOp : feeOps) { + feeOp.close(); + } } } @@ -226,10 +252,10 @@ private boolean feesRequired(String voteBase64) { } /** - * The fee-paying operation handle the vote requires in the base token, - * honoring the {@code priority} token preference; 0 when no fee is owed. + * Handles of every fee-paying operation {@code staple}'s votes require in + * the base token, honoring the {@code priority} token preference. */ - private int feeSend(int voteHandle, List priority) { + private int[] stapleFeeSends(VoteStaple staple, List priority) { int priorityPtr = 0; int priorityLen = 0; if (!priority.isEmpty()) { @@ -242,19 +268,23 @@ private int feeSend(int voteHandle, List priority) { priorityLen = priorityHandles.length * 4; } - return net.callInt("keeta_fee_send", voteHandle, baseToken.handle(), priorityPtr, priorityLen); - } + int listHandle = net.callInt("keeta_staple_fee_sends", staple.handle(), baseToken.handle(), priorityPtr, priorityLen); + if (listHandle == 0) { + return new int[0]; + } - /** {@code payer}'s last block hash (hex) among {@code blocks}, or {@code null} when absent. */ - private String tipHashFor(Account payer, List blocks) { - int[] blockHandles = new int[blocks.size()]; - for (int index = 0; index < blockHandles.length; index++) { - blockHandles[index] = blocks.get(index).handle(); + byte[] bytes = net.takeBytes(listHandle); + int[] handles = new int[bytes.length / 4]; + for (int index = 0; index < handles.length; index++) { + handles[index] = ByteBuffer.wrap(bytes, index * 4, 4).order(ByteOrder.LITTLE_ENDIAN).getInt(); } - int blocksPtr = net.writeHandles(blockHandles); - int tipHandle = net.callInt("keeta_blocks_tip_for", blocksPtr, blockHandles.length * 4, payer.handle()); + return handles; + } + /** {@code payer}'s last block hash (hex) in {@code staple}, or {@code null} when absent. */ + private String tipHashFor(VoteStaple staple, Account payer) { + int tipHandle = net.callInt("keeta_staple_tip_for", staple.handle(), payer.handle()); if (tipHandle == 0) { return null; } @@ -262,6 +292,16 @@ private String tipHashFor(Account payer, List blocks) { return net.takeString(tipHandle); } + /** The guest handles of {@code blocks}, in order. */ + private static int[] blockHandles(List blocks) { + int[] handles = new int[blocks.size()]; + for (int index = 0; index < handles.length; index++) { + handles[index] = blocks.get(index).handle(); + } + + return handles; + } + /** * Send {@code amount} of {@code token} from {@code from} to {@code to}, * publishing the send as its own staple. On this network a send credits the diff --git a/keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/VoteStaple.java b/keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/VoteStaple.java new file mode 100644 index 0000000..8f058ad --- /dev/null +++ b/keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/VoteStaple.java @@ -0,0 +1,26 @@ +package network.keeta.wasi; + +/** + * A validated vote staple: the blocks of a round plus the votes endorsing + * them, with the staple invariants (block/vote matching, validity window, + * canonical ordering) already enforced by the core module. Handed to a + * {@link GenerateFeeBlock} factory mid-transmit as the temporary round. + */ +public final class VoteStaple implements AutoCloseable { + private final KeetaNet net; + private final int handle; + + VoteStaple(KeetaNet net, int handle) { + this.net = net; + this.handle = handle; + } + + int handle() { + return handle; + } + + @Override + public void close() { + net.free("keeta_vote_staple_free", handle); + } +} diff --git a/keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/harness/FeeTransfer.java b/keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/harness/FeeTransfer.java index dda0702..47884a0 100644 --- a/keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/harness/FeeTransfer.java +++ b/keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/harness/FeeTransfer.java @@ -23,9 +23,8 @@ * mirror the Rust client's fee e2e tests: * *
    - *
  1. The sender pays its own fee through - * {@code TransmitOptions.withFeeSigner}, proving the {@code keeta_fee_send} - * path.
  2. + *
  3. The sender pays its own fee through {@code TransmitOptions.withFeeSigner}, + * proving the {@code keeta_staple_fee_sends} path.
  4. *
  5. A storage account (no key of its own) pays the fee with its trusted * owner signing the fee block through * {@code TransmitOptions.withFeeBlockFrom}, proving the delegated diff --git a/keetanetwork-client-wasi/src/p1/mod.rs b/keetanetwork-client-wasi/src/p1/mod.rs index 192583d..b78a893 100644 --- a/keetanetwork-client-wasi/src/p1/mod.rs +++ b/keetanetwork-client-wasi/src/p1/mod.rs @@ -8,7 +8,7 @@ use keetanetwork_bindings::error::CodedError; use keetanetwork_bindings::parse::adjust_method; use keetanetwork_bindings::registry::HandleRegistry; use keetanetwork_block::{AccountRef, Block, BlockBuilder, Operation, Permissions, UnsignedBlock}; -use keetanetwork_vote::Vote; +use keetanetwork_vote::{Vote, VoteStaple}; use keetanetwork_x509::certificates::Certificate; use crate::pure; @@ -23,6 +23,7 @@ struct State { builders: HandleRegistry, unsigned: HandleRegistry, votes: HandleRegistry, + staples: HandleRegistry, certificates: HandleRegistry, last_error: Option, } @@ -38,6 +39,7 @@ impl Default for State { builders: HandleRegistry::new("builder"), unsigned: HandleRegistry::new("unsigned-block"), votes: HandleRegistry::new("vote"), + staples: HandleRegistry::new("staple"), certificates: HandleRegistry::new("certificate"), last_error: None, } @@ -111,6 +113,12 @@ impl Registered for Vote { } } +impl Registered for VoteStaple { + fn table(state: &mut State) -> &mut HandleRegistry { + &mut state.staples + } +} + impl Registered for Certificate { fn table(state: &mut State) -> &mut HandleRegistry { &mut state.certificates @@ -1178,26 +1186,37 @@ pub unsafe extern "C" fn keeta_vote_staple_build( bytes_result(pure::vote_staple_build(blocks, votes, moment_millis)) } -/// The fee-paying `SEND` operation `vote` requires, denominated in -/// `base_token`, with optional `priority` token preference (a buffer of -/// little-endian `i32` account handles). +/// [`keeta_vote_staple_build`] as a live staple handle instead of transport +/// bytes, for querying the round (fees, chaining tips) before publishing. /// /// # Safety -/// See [`bytes_in`]; `priority` must be a `(ptr, len)` pair of `i32` handles. +/// See [`bytes_in`]; both buffers must be `(ptr, len)` pairs of `i32` handles. #[no_mangle] -pub unsafe extern "C" fn keeta_fee_send(vote: i32, base_token: i32, priority_ptr: i32, priority_len: i32) -> i32 { - let (Some(vote), Some(base_token), Some(priority)) = - (resolve::(vote), account(base_token), account_handles(priority_ptr, priority_len)) +pub unsafe extern "C" fn keeta_vote_staple_new( + blocks_ptr: i32, + blocks_len: i32, + votes_ptr: i32, + votes_len: i32, + moment_millis: i64, +) -> i32 { + let (Some(blocks), Some(votes)) = + (resolve_handles::(blocks_ptr, blocks_len), resolve_handles::(votes_ptr, votes_len)) else { return 0; }; - match pure::fee_send(&vote, &base_token, &priority) { - Some(operation) => store_operation(operation), - None => 0, + match pure::vote_staple_new(blocks, votes, moment_millis) { + Ok(staple) => store(staple), + Err(error) => fail(error), } } +/// Release a vote staple handle. +#[no_mangle] +pub extern "C" fn keeta_vote_staple_free(handle: i32) { + release::(handle); +} + /// 1 when `vote` obliges a fee block (a required, non-optional fee schedule), /// 0 otherwise or on a bad handle. #[no_mangle] @@ -1205,19 +1224,47 @@ pub extern "C" fn keeta_fees_required(vote: i32) -> i32 { resolve::(vote).is_some_and(|vote| pure::fees_required(&vote)) as i32 } -/// The hex hash (as a bytes handle) of `payer`'s last block among the handle -/// buffer `blocks`: the chaining point for a fee block joining the round. -/// Returns 0 when the payer has no block in the round. +/// Every fee-paying `SEND` operation the staple's votes require, as a bytes +/// handle of little-endian `i32` operation handles. Returns 0 when no fee is +/// owed. /// /// # Safety -/// See [`bytes_in`]; `blocks` must be a `(ptr, len)` pair of `i32` handles. +/// See [`bytes_in`]; `priority` must be a `(ptr, len)` pair of `i32` handles. +#[no_mangle] +pub unsafe extern "C" fn keeta_staple_fee_sends( + staple: i32, + base_token: i32, + priority_ptr: i32, + priority_len: i32, +) -> i32 { + let (Some(staple), Some(base_token), Some(priority)) = + (resolve::(staple), account(base_token), account_handles(priority_ptr, priority_len)) + else { + return 0; + }; + + let operations = pure::staple_fee_sends(&staple, &base_token, &priority); + if operations.is_empty() { + return 0; + } + + let handles = operations + .into_iter() + .flat_map(|operation| store_operation(operation).to_le_bytes()) + .collect(); + store_bytes(handles) +} + +/// The hex hash (as a bytes handle) of `payer`'s last block in `staple`: the +/// chaining point for a fee block joining the round. Returns 0 when the payer +/// has no block in the round. #[no_mangle] -pub unsafe extern "C" fn keeta_blocks_tip_for(blocks_ptr: i32, blocks_len: i32, payer: i32) -> i32 { - let (Some(blocks), Some(payer)) = (resolve_handles::(blocks_ptr, blocks_len), account(payer)) else { +pub extern "C" fn keeta_staple_tip_for(staple: i32, payer: i32) -> i32 { + let (Some(staple), Some(payer)) = (resolve::(staple), account(payer)) else { return 0; }; - match pure::blocks_tip_for(&blocks, &payer) { + match pure::staple_tip_for(&staple, &payer) { Some(hash) => store_bytes(hash.into_bytes()), None => 0, } diff --git a/keetanetwork-client-wasi/src/pure.rs b/keetanetwork-client-wasi/src/pure.rs index 32c375c..a111858 100644 --- a/keetanetwork-client-wasi/src/pure.rs +++ b/keetanetwork-client-wasi/src/pure.rs @@ -134,24 +134,31 @@ pub fn quote_to_hex(quote: &VoteQuote) -> String { hex::encode(quote.as_vote().as_bytes()) } -/// The fee-paying `SEND` operation `vote` requires. -pub fn fee_send(vote: &Vote, base_token: &AccountRef, priority: &[AccountRef]) -> Option { - vote.fee_send(base_token, priority).map(Operation::from) -} - /// Whether `vote` obliges a fee block: it carries a required (non-optional) /// fee schedule. See [`Fees::required`]. pub fn fees_required(vote: &Vote) -> bool { vote.fees().is_some_and(Fees::required) } -/// The hex hash of `account`'s last block among `blocks`, if any: the -/// chaining point for a fee block joining the round. -pub fn blocks_tip_for(blocks: &[Block], account: &AccountRef) -> Option { - blocks +/// Every fee-paying `SEND` operation the staple's votes require, in the base +/// token honoring the `priority` preference. +pub fn staple_fee_sends(staple: &VoteStaple, base_token: &AccountRef, priority: &[AccountRef]) -> Vec { + staple + .votes() + .iter() + .filter_map(|vote| vote.fee_send(base_token, priority)) + .map(Operation::from) + .collect() +} + +/// The hex hash of `payer`'s last block in `staple`, if any: the chaining +/// point for a fee block joining the round. +pub fn staple_tip_for(staple: &VoteStaple, payer: &AccountRef) -> Option { + staple + .blocks() .iter() .rev() - .find(|block| block.data().account() == account) + .find(|block| block.data().account() == payer) .map(block_hash) } @@ -190,13 +197,18 @@ pub fn staple_from_hex(value: &str, moment_millis: i64) -> Result, votes: Vec, moment_millis: i64) -> Result, CodedError> { - let staple = VoteStaple::try_new(blocks, votes, ValidationConfig::default(), block_time(moment_millis)?) - .map_err(CodedError::from)?; +/// Assemble a [`VoteStaple`] from signed `blocks` and the `votes` endorsing +/// them, enforcing the staple invariants at `moment_millis`. +pub fn vote_staple_new(blocks: Vec, votes: Vec, moment_millis: i64) -> Result { + VoteStaple::try_new(blocks, votes, ValidationConfig::default(), block_time(moment_millis)?) + .map_err(CodedError::from) +} - Ok(staple.as_bytes().to_vec()) +/// [`vote_staple_new`] flattened to the staple's publishable transport bytes. +pub fn vote_staple_build(blocks: Vec, votes: Vec, moment_millis: i64) -> Result, CodedError> { + Ok(vote_staple_new(blocks, votes, moment_millis)? + .as_bytes() + .to_vec()) } // --------------------------------------------------------------------------- @@ -424,15 +436,15 @@ mod tests { sign_unsigned(unsigned).expect("signing must succeed") } - /// A signed vote over one block hash, optionally carrying `fees`. - fn signed_vote(issuer: &AccountRef, fees: Option) -> Vote { + /// A signed vote over `block`, optionally carrying `fees`. + fn signed_vote(issuer: &AccountRef, block: BlockHash, fees: Option) -> Vote { let from = block_time(1_700_000_000_000).expect("timestamp must be in range"); let to = block_time(1_700_000_600_000).expect("timestamp must be in range"); let mut builder = VoteBuilder::new() .serial(1u8) .issuer(Arc::clone(issuer)) .validity(from, to) - .add_block(BlockHash::from([7u8; 32])); + .add_block(block); if let Some(fees) = fees { builder = builder.fees(fees); @@ -443,17 +455,44 @@ mod tests { .expect("the vote must sign") } + /// A one-block staple endorsed by one vote carrying `fees`. + fn signed_staple(user: &AccountRef, rep: AccountRef, issuer: &AccountRef, fees: Option) -> VoteStaple { + let block = signed_block(user, rep); + let vote = signed_vote(issuer, block.hash(), fees); + + vote_staple_new(vec![block], vec![vote], 1_700_000_300_000).expect("the staple must build") + } + + /// A required (non-optional) one-entry fee schedule of `amount`. + fn required_fees(amount: u64) -> Fees { + let fee = Fee { amount: Amount::from(amount), pay_to: None, token: None }; + Fees::from_entries(false, vec![fee]).expect("fees must build") + } + #[test] - fn blocks_tip_for_finds_the_accounts_last_block() { + fn staple_tip_for_finds_the_payers_last_block() { let seed = generate_seed().expect("seed generation must succeed"); - let user = account_from_seed(&seed, 0, DEFAULT_ALGORITHM).expect("derivation must succeed"); - let rep = account_from_seed(&seed, 1, DEFAULT_ALGORITHM).expect("derivation must succeed"); - let outsider = account_from_seed(&seed, 2, DEFAULT_ALGORITHM).expect("derivation must succeed"); + let derive = |index| account_from_seed(&seed, index, DEFAULT_ALGORITHM).expect("derivation must succeed"); + let (user, rep, outsider, issuer) = (derive(0), derive(1), derive(2), derive(3)); + + let staple = signed_staple(&user, rep, &issuer, None); + let tip = staple.blocks().last().map(block_hash); + assert_eq!(staple_tip_for(&staple, &user), tip); + assert_eq!(staple_tip_for(&staple, &outsider), None); + } + + #[test] + fn staple_fee_sends_mirror_the_votes_fee_schedule() { + let seed = generate_seed().expect("seed generation must succeed"); + let derive = |index| account_from_seed(&seed, index, DEFAULT_ALGORITHM).expect("derivation must succeed"); + let (user, rep, issuer) = (derive(0), derive(1), derive(2)); + let base = base_token(0).expect("the base token must derive"); + + let free = signed_staple(&user, rep.clone(), &issuer, None); + assert!(staple_fee_sends(&free, &base, &[]).is_empty()); - let block = signed_block(&user, rep); - let blocks = [block.clone()]; - assert_eq!(blocks_tip_for(&blocks, &user), Some(block_hash(&block))); - assert_eq!(blocks_tip_for(&blocks, &outsider), None); + let charged = signed_staple(&user, rep, &issuer, Some(required_fees(10))); + assert_eq!(staple_fee_sends(&charged, &base, &[]).len(), 1); } #[test] @@ -462,11 +501,11 @@ mod tests { let issuer = account_from_seed(&seed, 0, DEFAULT_ALGORITHM).expect("derivation must succeed"); let fee = |amount: u64| Fee { amount: Amount::from(amount), pay_to: None, token: None }; - let required = Fees::from_entries(false, vec![fee(10)]).expect("fees must build"); let optional = Fees::from_entries(false, vec![fee(10), fee(0)]).expect("fees must build"); - assert!(!fees_required(&signed_vote(&issuer, None))); - assert!(fees_required(&signed_vote(&issuer, Some(required)))); - assert!(!fees_required(&signed_vote(&issuer, Some(optional)))); + let block = BlockHash::from([7u8; 32]); + assert!(!fees_required(&signed_vote(&issuer, block, None))); + assert!(fees_required(&signed_vote(&issuer, block, Some(required_fees(10))))); + assert!(!fees_required(&signed_vote(&issuer, block, Some(optional)))); } #[test] From b4468dbc8e2a2e78a3cb9efc2b5e23a86a8f634a Mon Sep 17 00:00:00 2001 From: Tanveer Wahid Date: Thu, 23 Jul 2026 15:04:37 -0700 Subject: [PATCH 3/8] chore: cleanup --- .../src/main/java/network/keeta/wasi/TransmitOptions.java | 2 +- .../java/src/main/java/network/keeta/wasi/UserClient.java | 4 ++-- .../java/src/main/java/network/keeta/wasi/VoteStaple.java | 4 ++-- .../main/java/network/keeta/wasi/harness/FeeTransfer.java | 4 ++-- keetanetwork-client-wasi/src/p1/mod.rs | 4 ++-- keetanetwork-client/src/client.rs | 7 +++---- keetanetwork-client/src/error.rs | 8 ++++---- keetanetwork-client/src/model.rs | 3 +-- keetanetwork-client/src/user.rs | 5 ++--- keetanetwork-client/tests/e2e.rs | 3 +-- 10 files changed, 20 insertions(+), 24 deletions(-) diff --git a/keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/TransmitOptions.java b/keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/TransmitOptions.java index 8f5a0e1..68a1299 100644 --- a/keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/TransmitOptions.java +++ b/keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/TransmitOptions.java @@ -6,7 +6,7 @@ /** * Publish-time options for {@link UserClient#transmit(List, TransmitOptions)}. - * Fee payment is a {@link GenerateFeeBlock} factory; + * Fee payment is a {@link GenerateFeeBlock} factory. * {@link #withFeeSigner(Account)} and * {@link #withFeeBlockFrom(Account, Account)} cover the common shapes. */ diff --git a/keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/UserClient.java b/keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/UserClient.java index 1101c4c..92221e8 100644 --- a/keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/UserClient.java +++ b/keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/UserClient.java @@ -126,7 +126,7 @@ public void transmit(List blocks, TransmitOptions options) { List all = blocks; List encodedAll = encoded; if (feeBlock != null) { - // The fee block joins the permanent round last; the node + // The fee block joins the permanent round last. The node // recognizes it by its FEE purpose and escalates the temporary // votes over the original blocks. all = new ArrayList<>(blocks); @@ -171,7 +171,7 @@ private void publishStaple(List blocks, String permanentVoteB /** * Assemble a validated {@link VoteStaple} over {@code blocks} and the - * base64 vote endorsing them, enforcing the staple invariants now. + * base64 vote endorsing them, enforcing the staple invariants. */ private VoteStaple stapleFor(List blocks, String voteBase64) { int voteHandle = voteHandle(voteBase64); diff --git a/keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/VoteStaple.java b/keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/VoteStaple.java index 8f058ad..70330ce 100644 --- a/keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/VoteStaple.java +++ b/keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/VoteStaple.java @@ -3,8 +3,8 @@ /** * A validated vote staple: the blocks of a round plus the votes endorsing * them, with the staple invariants (block/vote matching, validity window, - * canonical ordering) already enforced by the core module. Handed to a - * {@link GenerateFeeBlock} factory mid-transmit as the temporary round. + * canonical ordering) enforced by the core module at construction. Handed to + * a {@link GenerateFeeBlock} factory mid-transmit as the temporary round. */ public final class VoteStaple implements AutoCloseable { private final KeetaNet net; diff --git a/keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/harness/FeeTransfer.java b/keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/harness/FeeTransfer.java index 47884a0..cdb37ff 100644 --- a/keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/harness/FeeTransfer.java +++ b/keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/harness/FeeTransfer.java @@ -19,8 +19,8 @@ * End-to-end fee harness test exercising the bound Java SDK against a fee-enforcing * node. * - *

    The node charges a flat base-token fee on every transaction. Three probes - * mirror the Rust client's fee e2e tests: + *

    The node charges a flat base-token fee on every transaction. Three + * probes cover the fee paths: * *

      *
    1. The sender pays its own fee through {@code TransmitOptions.withFeeSigner}, diff --git a/keetanetwork-client-wasi/src/p1/mod.rs b/keetanetwork-client-wasi/src/p1/mod.rs index b78a893..4f45dcc 100644 --- a/keetanetwork-client-wasi/src/p1/mod.rs +++ b/keetanetwork-client-wasi/src/p1/mod.rs @@ -1270,8 +1270,8 @@ pub extern "C" fn keeta_staple_tip_for(staple: i32, payer: i32) -> i32 { } } -/// The base token account handle for `network` (the implicit fee currency); -/// 0 on failure. +/// The base token account handle for `network` (the implicit fee currency). +/// Returns 0 on failure. #[no_mangle] pub extern "C" fn keeta_base_token(network: i64) -> i32 { let Ok(network) = u64::try_from(network) else { diff --git a/keetanetwork-client/src/client.rs b/keetanetwork-client/src/client.rs index 432b16d..1bbe1c4 100644 --- a/keetanetwork-client/src/client.rs +++ b/keetanetwork-client/src/client.rs @@ -795,7 +795,7 @@ impl KeetaClient { /// /// - [`ClientError::NoRepresentatives`] -- no representative is configured to vote /// - [`ClientError::QuorumNotReached`] -- the returned votes did not reach quorum weight - /// - [`ClientError::FeeRequired`] -- the node requires a fee but no `generate_fee_block` was supplied + /// - [`ClientError::FeeRequired`] -- the node requires a fee but `generate_fee_block` is absent /// - [`ClientError::Node`] -- a representative rejected the blocks or staple pub async fn transmit(&self, blocks: &[Block], options: TransmitOptions) -> Result { self.transmit_with_optional_fee(blocks, &options).await @@ -1247,8 +1247,7 @@ impl KeetaClient { } /// Resolve the fee block for a round whose votes require one by invoking - /// `options.generate_fee_block` with the temporary-round staple, like the - /// reference implementation's `generateFeeBlock`. + /// `options.generate_fee_block` with the temporary-round staple. async fn fee_block_from_options( &self, blocks: &[Block], @@ -1303,7 +1302,7 @@ impl KeetaClient { /// # Errors /// /// - [`ClientError::NoRepresentatives`] -- no representative is configured to vote - /// - [`ClientError::FeeRequired`] -- the node requires a fee but no `generate_fee_block` was supplied + /// - [`ClientError::FeeRequired`] -- the node requires a fee but `generate_fee_block` is absent /// - [`ClientError::QuorumNotReached`] -- the returned votes did not reach quorum weight /// - [`ClientError::Node`] -- a representative rejected the block or staple pub async fn publish(&self, block: Block, options: TransmitOptions) -> Result { diff --git a/keetanetwork-client/src/error.rs b/keetanetwork-client/src/error.rs index 8e1c2bc..ba8adc3 100644 --- a/keetanetwork-client/src/error.rs +++ b/keetanetwork-client/src/error.rs @@ -106,10 +106,10 @@ pub enum ClientError { #[snafu(display("node response omitted the version"))] MissingVersion, - /// The node's votes require a fee block but no signer was supplied to - /// originate one (use [`send`](crate::KeetaClient::send) or another - /// signer-bearing path). - #[snafu(display("node votes require a fee block but no signer was supplied"))] + /// The node's votes require a fee block but no fee-block factory is set + /// to originate one (see + /// [`TransmitOptions::generate_fee_block`](crate::TransmitOptions)). + #[snafu(display("node votes require a fee block but no fee-block factory is set"))] FeeRequired, /// An account address could not be parsed or derived: a malformed address diff --git a/keetanetwork-client/src/model.rs b/keetanetwork-client/src/model.rs index 32accad..29648f0 100644 --- a/keetanetwork-client/src/model.rs +++ b/keetanetwork-client/src/model.rs @@ -1,5 +1,4 @@ -//! Domain-typed request/response models exposed by -//! [`KeetaClient`](crate::KeetaClient). +//! Domain-typed request/response models exposed by [`KeetaClient`]. use alloc::boxed::Box; use alloc::string::String; diff --git a/keetanetwork-client/src/user.rs b/keetanetwork-client/src/user.rs index 83e31b7..a005b6a 100644 --- a/keetanetwork-client/src/user.rs +++ b/keetanetwork-client/src/user.rs @@ -644,8 +644,7 @@ impl UserClient { } /// Keep the caller's fee-block factory when supplied, otherwise fall back - /// to the bound signer paying for itself, mirroring the reference - /// implementation's `UserClient` default `generateFeeBlock`. + /// to the bound signer paying for itself. fn or_default_fee_payer(&self, options: TransmitOptions) -> Result { if options.generate_fee_block.is_some() { return Ok(options); @@ -749,7 +748,7 @@ fn operation_involves(operation: &Operation, account: &impl AccountPublicKey) -> } /// Whether two accounts share the same public-key identity (algorithm plus -/// raw key bytes), mirroring the TS `comparePublicKey`. +/// raw key bytes). fn same_account(candidate: &AccountRef, account: &impl AccountPublicKey) -> bool { candidate.to_keypair_type() == account.to_keypair_type() && candidate.as_public_key_bytes() == account.as_public_key_bytes() diff --git a/keetanetwork-client/tests/e2e.rs b/keetanetwork-client/tests/e2e.rs index 71e046e..8ba8b04 100644 --- a/keetanetwork-client/tests/e2e.rs +++ b/keetanetwork-client/tests/e2e.rs @@ -714,8 +714,7 @@ async fn test_with_fee_signer_third_party_pays() -> Result<(), Box Result<(), Box> { let (_node, client, accounts) = fee_fixture(); From 9d21c9ea55ba65a813a5bd030768766c67d98da6 Mon Sep 17 00:00:00 2001 From: Tanveer Wahid Date: Thu, 23 Jul 2026 15:09:17 -0700 Subject: [PATCH 4/8] fix: require fee block if fees are required --- .../java/network/keeta/wasi/UserClient.java | 13 +++++++------ .../keeta/wasi/harness/FeeTransfer.java | 18 ++++++++++-------- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/UserClient.java b/keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/UserClient.java index 92221e8..bd9f302 100644 --- a/keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/UserClient.java +++ b/keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/UserClient.java @@ -102,8 +102,8 @@ public void transmit(List blocks) { /** * Publish {@code blocks} as one atomic staple. When {@code options} * carries a fee-block factory it is invoked with the temporary round, and - * any block it returns joins the permanent round and the staple. Without - * a factory, a vote requiring a fee fails with {@code FEE_REQUIRED} + * any block it returns joins the permanent round and the staple. A vote + * requiring a fee that no fee block pays fails with {@code FEE_REQUIRED} * before anything is published. */ public void transmit(List blocks, TransmitOptions options) { @@ -111,10 +111,6 @@ public void transmit(List blocks, TransmitOptions options) { String temporary = requestVote(encoded, null); GenerateFeeBlock factory = options.generateFeeBlock(); - if (factory == null && feesRequired(temporary)) { - throw new KeetaException("FEE_REQUIRED", "votes require a fee but no fee-block factory was supplied"); - } - Block.SignedBlock feeBlock = null; if (factory != null) { try (VoteStaple staple = stapleFor(blocks, temporary)) { @@ -122,6 +118,11 @@ public void transmit(List blocks, TransmitOptions options) { } } + if (feeBlock == null && feesRequired(temporary)) { + throw new KeetaException("FEE_REQUIRED", + "votes require a fee but no fee block was produced; set a fee-block factory on TransmitOptions"); + } + try { List all = blocks; List encodedAll = encoded; diff --git a/keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/harness/FeeTransfer.java b/keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/harness/FeeTransfer.java index cdb37ff..2b824f3 100644 --- a/keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/harness/FeeTransfer.java +++ b/keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/harness/FeeTransfer.java @@ -29,9 +29,8 @@ * owner signing the fee block through * {@code TransmitOptions.withFeeBlockFrom}, proving the delegated * account/signer split.
    2. - *
    3. A fee-less transmit fails with a typed {@code FEE_REQUIRED} before - * anything is published. Last: the node's temporary vote pins the account - * head until it expires, blocking any further block on that head.
    4. + *
    5. A transmit paying no fee fails with a typed {@code FEE_REQUIRED} + * before anything is published.
    6. *
    */ public final class FeeTransfer { @@ -67,8 +66,10 @@ public static void main(String[] args) { } /** - * A fee-less transmit must fail with a typed {@code FEE_REQUIRED} before - * anything is published: the recipient's balance is unchanged after. + * A transmit paying no fee must fail with a typed {@code FEE_REQUIRED} + * before anything is published: the recipient's balance is unchanged + * after. Probes the worst case, a factory that declines by returning + * {@code null}, which shares its gate with the no-factory path. */ private static void feeRequiredIsTyped(Keeta keeta, UserClient client, Account trusted, Account recipient, Account base) { @@ -76,11 +77,12 @@ private static void feeRequiredIsTyped(Keeta keeta, UserClient client, Account t Block.SignedBlock send = sendBlock(keeta, client, trusted, recipient, AMOUNT, base); try (send) { - client.transmit(List.of(send)); - check(false, "a fee-less transmit must throw FEE_REQUIRED"); + TransmitOptions declines = TransmitOptions.defaults().withGenerateFeeBlock((c, staple, priority) -> null); + client.transmit(List.of(send), declines); + check(false, "a transmit paying no fee must throw FEE_REQUIRED"); } catch (KeetaException exception) { check("FEE_REQUIRED".equals(exception.code()), - "a fee-less transmit must fail with FEE_REQUIRED, got " + exception.code()); + "a transmit paying no fee must fail with FEE_REQUIRED, got " + exception.code()); } BigInteger recipientAfter = parseHex(client.balance(recipient, base)); From 11c9a4e14a819727ee54f9205cbd44a4999cf414 Mon Sep 17 00:00:00 2001 From: Tanveer Wahid Date: Thu, 23 Jul 2026 15:10:26 -0700 Subject: [PATCH 5/8] fix(ci): use cargo lock --- .github/workflows/ci.yml | 2 +- Makefile | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c40bcfd..3b2c68b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -211,7 +211,7 @@ jobs: uses: dsherret/rust-toolchain-file@3551321aa44dd44a0393eb3b6bdfbc5d25ecf621 # v1 - name: Install cargo-audit - run: cargo install cargo-audit + run: cargo install cargo-audit --locked - name: Run security audit run: make audit diff --git a/Makefile b/Makefile index f626287..b2e8d4a 100644 --- a/Makefile +++ b/Makefile @@ -232,7 +232,7 @@ developer: echo "Cargo version: $$(cargo --version)"; \ echo "Installing development tools..."; \ $(MAKE) coverage-setup; \ - cargo install cargo-audit --quiet || echo "WARNING: cargo-audit installation failed or already installed"; \ + cargo install cargo-audit --locked --quiet || echo "WARNING: cargo-audit installation failed or already installed"; \ echo "Installing script dependencies..."; \ if ! command -v jq > /dev/null 2>&1; then \ echo "Installing jq..."; \ From f115bcebe43459b93781a29cacfbfc0b9a21c5d0 Mon Sep 17 00:00:00 2001 From: Tanveer Wahid Date: Thu, 23 Jul 2026 15:17:41 -0700 Subject: [PATCH 6/8] fix(wasi): java transmit --- .../java/network/keeta/wasi/UserClient.java | 26 +++++++++++-------- .../keeta/wasi/harness/FeeTransfer.java | 2 +- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/UserClient.java b/keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/UserClient.java index bd9f302..81a1222 100644 --- a/keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/UserClient.java +++ b/keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/UserClient.java @@ -100,27 +100,31 @@ public void transmit(List blocks) { } /** - * Publish {@code blocks} as one atomic staple. When {@code options} - * carries a fee-block factory it is invoked with the temporary round, and - * any block it returns joins the permanent round and the staple. A vote - * requiring a fee that no fee block pays fails with {@code FEE_REQUIRED} - * before anything is published. + * Publish {@code blocks} as one atomic staple. When the temporary + * round's votes require a fee, the fee-block factory in {@code options} + * is invoked with that round and its block joins the permanent round and + * the staple. A required fee with no factory, or a factory returning + * {@code null}, fails with {@code FEE_REQUIRED} before anything is + * published. */ public void transmit(List blocks, TransmitOptions options) { List encoded = encode(blocks); String temporary = requestVote(encoded, null); - GenerateFeeBlock factory = options.generateFeeBlock(); Block.SignedBlock feeBlock = null; - if (factory != null) { + if (feesRequired(temporary)) { + GenerateFeeBlock factory = options.generateFeeBlock(); + if (factory == null) { + throw new KeetaException("FEE_REQUIRED", "votes require a fee but no fee-block factory is set"); + } + try (VoteStaple staple = stapleFor(blocks, temporary)) { feeBlock = factory.generate(this, staple, options.feeTokenPriority()); } - } - if (feeBlock == null && feesRequired(temporary)) { - throw new KeetaException("FEE_REQUIRED", - "votes require a fee but no fee block was produced; set a fee-block factory on TransmitOptions"); + if (feeBlock == null) { + throw new KeetaException("FEE_REQUIRED", "votes require a fee but the fee-block factory produced none"); + } } try { diff --git a/keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/harness/FeeTransfer.java b/keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/harness/FeeTransfer.java index 2b824f3..19ec66a 100644 --- a/keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/harness/FeeTransfer.java +++ b/keetanetwork-client-wasi/bindings/java/src/main/java/network/keeta/wasi/harness/FeeTransfer.java @@ -69,7 +69,7 @@ public static void main(String[] args) { * A transmit paying no fee must fail with a typed {@code FEE_REQUIRED} * before anything is published: the recipient's balance is unchanged * after. Probes the worst case, a factory that declines by returning - * {@code null}, which shares its gate with the no-factory path. + * {@code null}. */ private static void feeRequiredIsTyped(Keeta keeta, UserClient client, Account trusted, Account recipient, Account base) { From a29fdf80914a626ca53ebec7d82aab87190134f0 Mon Sep 17 00:00:00 2001 From: Tanveer Wahid Date: Thu, 23 Jul 2026 15:59:27 -0700 Subject: [PATCH 7/8] feat(wasm): support wasm as well --- keetanetwork-block/src/testing.rs | 20 +- keetanetwork-client-wasm/src/client.rs | 21 ++ keetanetwork-client-wasm/src/options.rs | 89 +++++++- keetanetwork-client-wasm/tests/fee.spec.ts | 198 ++++++++++++++++++ .../tests/playwright.config.ts | 57 ++++- keetanetwork-client-wasm/tests/serve.ts | 9 +- keetanetwork-client/src/error.rs | 13 ++ keetanetwork-client/tests/e2e.rs | 19 +- 8 files changed, 397 insertions(+), 29 deletions(-) create mode 100644 keetanetwork-client-wasm/tests/fee.spec.ts diff --git a/keetanetwork-block/src/testing.rs b/keetanetwork-block/src/testing.rs index b432d73..cb8d2bd 100644 --- a/keetanetwork-block/src/testing.rs +++ b/keetanetwork-block/src/testing.rs @@ -1,9 +1,9 @@ -//! Deterministic account, operation, and builder factories shared by -//! every Keetanetwork test suite. +//! Account, operation, and builder factories shared by every Keetanetwork +//! test suite. use alloc::sync::Arc; -use keetanetwork_account::{Account, Accountable, GenericAccount, KeyED25519, KeyPairType, Keyable}; +use keetanetwork_account::{Account, Accountable, GenericAccount, KeyED25519, KeyPairType, Keyable, Seed}; use keetanetwork_crypto::prelude::IntoSecret; use crate::amount::Amount; @@ -12,17 +12,27 @@ use crate::operation::Send; use crate::signer::AccountRef; use crate::time::BlockTime; -fn base_account(seed_byte: u8) -> Account { - let seed = [seed_byte; 32].into_secret(); +fn seeded_account(seed: Seed) -> Account { Account::::try_from(Accountable::KeyAndType(Keyable::Seed((seed, 0)), KeyPairType::ED25519)) .expect("test account construction must succeed") } +fn base_account(seed_byte: u8) -> Account { + seeded_account([seed_byte; 32].into_secret()) +} + /// A deterministic ed25519 keyed account. pub fn generate_ed25519_ref(seed_byte: u8) -> AccountRef { Arc::new(GenericAccount::Ed25519(base_account(seed_byte))) } +/// A randomly keyed ed25519 account, for tests whose contract is "any +/// account" rather than a particular key. +pub fn random_ed25519_ref() -> AccountRef { + let seed = Account::::generate_random_seed().expect("test seed generation must succeed"); + Arc::new(GenericAccount::Ed25519(seeded_account(seed))) +} + /// A deterministic identifier account derived from a seed-byte owner. /// /// Equivalent to `derive_identifier(&generate_ed25519_ref(seed_byte), ..)`; diff --git a/keetanetwork-client-wasm/src/client.rs b/keetanetwork-client-wasm/src/client.rs index 30c7c65..592745f 100644 --- a/keetanetwork-client-wasm/src/client.rs +++ b/keetanetwork-client-wasm/src/client.rs @@ -541,6 +541,27 @@ impl KeetaClient { Ok(staple.map(VoteStaple::from)) } + /// Build and sign the fee block `staple`'s votes require: `account` pays, + /// `signer` signs (the same account for a self-signing payer), and + /// `priority` orders the token choice when the fee is payable in several. + /// The common implementation for a `setGenerateFeeBlock` factory. + #[wasm_bindgen(js_name = buildFeeBlock)] + pub async fn build_fee_block( + &self, + staple: &VoteStaple, + account: &Account, + signer: &Account, + priority: Vec, + ) -> JsResult { + let priority: Vec = priority.iter().map(Account::inner).collect(); + let block = self + .inner + .build_fee_block(staple.inner(), &account.inner(), &signer.inner(), &priority) + .await + .map_err(client_error)?; + Ok(Block::from(block)) + } + /// Start a transaction originated by `account`. pub fn builder(&self, account: &Account) -> Builder { Builder::new(self.inner.builder(&account.inner())) diff --git a/keetanetwork-client-wasm/src/options.rs b/keetanetwork-client-wasm/src/options.rs index d26e5e3..6edee41 100644 --- a/keetanetwork-client-wasm/src/options.rs +++ b/keetanetwork-client-wasm/src/options.rs @@ -1,15 +1,29 @@ //! JS `TransmitOptions`: publish-time controls passed to publish/transmit. +use alloc::boxed::Box; +use alloc::string::String; +use alloc::sync::Arc; +use alloc::vec::Vec; +use core::fmt; use core::mem; -use keetanetwork_client::TransmitOptions as Core; +use js_sys::{Array, Function, Promise}; +use keetanetwork_block::{AccountRef, Block as CoreBlock}; +use keetanetwork_client::{ + ClientError, KeetaClient as CoreClient, TransmitOptions as Core, VoteStaple as CoreVoteStaple, +}; +use wasm_bindgen::convert::TryFromJsValue; use wasm_bindgen::prelude::wasm_bindgen; +use wasm_bindgen::{JsCast, JsValue}; +use wasm_bindgen_futures::JsFuture; use crate::account::Account; +use crate::block::{Block, VoteStaple}; +use crate::client::KeetaClient; use crate::vote::VoteQuote; /// Controls for a publish or transmit round. Construct with `new()` for -/// defaults, then layer on a fee signer, fee-token preference, or quotes. +/// defaults, then layer on a fee payer, fee-token preference, or quotes. #[wasm_bindgen] #[derive(Default)] pub struct TransmitOptions { @@ -31,6 +45,13 @@ impl TransmitOptions { self.inner = mem::take(&mut self.inner).with_fee_signer(&signer.inner()); } + /// Pay any required fee from `account`, signed by `signer`. For a payer + /// that signs for itself, prefer `setFeeSigner`. + #[wasm_bindgen(js_name = setFeeBlockFrom)] + pub fn set_fee_block_from(&mut self, account: &Account, signer: &Account) { + self.inner = mem::take(&mut self.inner).with_fee_block_from(&account.inner(), &signer.inner()); + } + /// Append a token to the fee-token preference order, highest priority /// first, used when a fee is payable in several tokens. #[wasm_bindgen(js_name = addFeeTokenPriority)] @@ -44,6 +65,70 @@ impl TransmitOptions { pub fn add_quote(&mut self, quote: &VoteQuote) { self.inner.quotes.push(quote.inner()); } + + /// Custom fee-block factory `(client, staple, priority) => Block`, invoked + /// when the votes require a fee; the block it resolves to (a promise is + /// awaited) joins the staple. `KeetaClient.buildFeeBlock` is the common + /// implementation. For a payer known up front, prefer `setFeeSigner` or + /// `setFeeBlockFrom`. + #[wasm_bindgen(js_name = setGenerateFeeBlock)] + pub fn set_generate_fee_block(&mut self, factory: &Function) { + let factory = factory.clone(); + self.inner.generate_fee_block = Some(Arc::new(move |client, staple, priority| { + let factory = factory.clone(); + Box::pin(async move { generate_via_js(&factory, client, staple, priority).await }) + })); + } +} + +/// Invoke the JS fee-block factory and coerce its settled result to a block. +async fn generate_via_js( + factory: &Function, + client: CoreClient, + staple: CoreVoteStaple, + priority: Vec, +) -> Result { + let client = JsValue::from(KeetaClient::from(client)); + let staple = JsValue::from(VoteStaple::from(staple)); + let tokens = Array::new(); + for token in priority { + tokens.push(&JsValue::from(Account::from(token))); + } + + let returned = factory + .call3(&JsValue::NULL, &client, &staple, &tokens) + .map_err(factory_failure)?; + let settled = JsFuture::from(Promise::resolve(&returned)) + .await + .map_err(factory_failure)?; + let block = Block::try_from_js_value(settled) + .map_err(|_| factory_failure(JsValue::from_str("fee-block factory must resolve to a Block")))?; + + Ok(block.inner()) +} + +/// The thrown JS value, stringified: a `JsValue` cannot itself cross into the +/// core error chain as a `core::error::Error` source. +#[derive(Debug)] +struct FactoryFailure(String); + +impl fmt::Display for FactoryFailure { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +impl core::error::Error for FactoryFailure {} + +/// Project a value thrown by the JS factory onto the client error taxonomy. +fn factory_failure(thrown: JsValue) -> ClientError { + let message = thrown + .dyn_ref::() + .map(|error| String::from(error.message())) + .or_else(|| thrown.as_string()) + .unwrap_or_else(|| String::from("fee-block factory threw a non-Error value")); + + ClientError::FeeBlockFactory { source: Box::new(FactoryFailure(message)) } } impl TransmitOptions { diff --git a/keetanetwork-client-wasm/tests/fee.spec.ts b/keetanetwork-client-wasm/tests/fee.spec.ts new file mode 100644 index 0000000..77eb541 --- /dev/null +++ b/keetanetwork-client-wasm/tests/fee.spec.ts @@ -0,0 +1,198 @@ +// Fee payment surface against a fee-enforcing node (the second webServer in +// playwright.config.ts): the votes on every staple demand a flat base-token +// fee, so each probe exercises a real FEE_REQUIRED round. + +import { expect, test } from '@playwright/test'; + +import type * as Keeta from '../pkg/keetanetwork_client_wasm'; +import { FEE_PORT } from './playwright.config'; + +const FEE_BASE = `http://localhost:${FEE_PORT}`; + +interface NodeInfo { + api: string; + network: string; + baseToken: string; + trusted: string; + recipient: string; + trustedSeedHex: string; + amount: string; + fee: string; +} + +// One recipient per crediting probe so each credited balance is the sole +// consequence of that probe's transfer. +const SIGNER_RECIPIENT_SEED_HEX = '81'.repeat(32); +const FACTORY_RECIPIENT_SEED_HEX = '82'.repeat(32); + +// The third party that pays the fee in the custom-factory probe. +const PAYER_SEED_HEX = '83'.repeat(32); + +// The probes whose transmit must fail with a typed code. Each gets its own +// funded sender so its abandoned temporary vote cannot collide with the +// trusted account's later staples. +const FAILING_PROBES = [ + { reason: 'a required fee without a factory', code: 'FEE_REQUIRED', senderSeedHex: '84'.repeat(32) }, + { reason: 'a throwing factory', code: 'FEE_BLOCK_FACTORY', senderSeedHex: '85'.repeat(32) }, +] as const; + +test.describe('fee payment surface', () => { + let info: NodeInfo; + + test.beforeEach(async ({ page }) => { + const response = await page.request.get(`${FEE_BASE}/node-info.json`); + info = (await response.json()) as NodeInfo; + await page.goto(`${FEE_BASE}/tests/index.html`); + await page.waitForFunction(() => (window as unknown as { wasmReady?: boolean }).wasmReady === true); + }); + + test('setFeeSigner pays the required fee from the sender', async ({ page }) => { + const result: { transmitted: boolean; senderDebit: string; recipientBalance: string } = await page.evaluate( + async (cfg: { info: NodeInfo; recipientSeedHex: string }) => { + const { KeetaClient, UserClient, Account, TransmitOptions } = ( + window as unknown as { keeta: typeof Keeta } + ).keeta; + + const client = new KeetaClient(cfg.info.api).withNetwork(cfg.info.network); + const trusted = Account.fromSeed(cfg.info.trustedSeedHex, 0, 'ed25519'); + const recipient = Account.fromSeed(cfg.recipientSeedHex, 0, 'ed25519'); + const token = Account.fromPublicKeyString(cfg.info.baseToken); + const user = UserClient.fromClient(client, trusted); + + const before = await client.balance(trusted, token); + + const builder = user.initBuilder(); + builder.send(recipient, cfg.info.amount, token); + const blocks = await builder.build(); + + const options = new TransmitOptions(); + options.setFeeSigner(trusted); + const transmitted = await user.transmit(blocks, options); + + const after = await client.balance(trusted, token); + const recipientBalance = await client.balance(recipient, token); + + return { + transmitted, + senderDebit: (BigInt(before) - BigInt(after)).toString(), + recipientBalance, + }; + }, + { info, recipientSeedHex: SIGNER_RECIPIENT_SEED_HEX }, + ); + + expect(result.transmitted, 'the fee-enforcing node must accept the fee-paying staple').toBe(true); + expect(BigInt(result.senderDebit), 'the sender must be debited the amount plus the fee').toBe( + BigInt(info.amount) + BigInt(info.fee), + ); + expect(BigInt(result.recipientBalance), 'the recipient must be credited the amount alone').toBe( + BigInt(info.amount), + ); + }); + + test('setGenerateFeeBlock routes the fee to a third-party payer via buildFeeBlock', async ({ page }) => { + const result: { + transmitted: boolean; + senderDebit: string; + payerDebit: string; + recipientBalance: string; + } = await page.evaluate( + async (cfg: { info: NodeInfo; recipientSeedHex: string; payerSeedHex: string; payerFund: string }) => { + const { KeetaClient, UserClient, Account, TransmitOptions } = ( + window as unknown as { keeta: typeof Keeta } + ).keeta; + + const client = new KeetaClient(cfg.info.api).withNetwork(cfg.info.network); + const trusted = Account.fromSeed(cfg.info.trustedSeedHex, 0, 'ed25519'); + const recipient = Account.fromSeed(cfg.recipientSeedHex, 0, 'ed25519'); + const payer = Account.fromSeed(cfg.payerSeedHex, 0, 'ed25519'); + const token = Account.fromPublicKeyString(cfg.info.baseToken); + const user = UserClient.fromClient(client, trusted); + + // The payer needs base-token funds to cover the fee it will send. + await client.send(trusted, payer, cfg.payerFund, token); + + const senderBefore = await client.balance(trusted, token); + const payerBefore = await client.balance(payer, token); + + const builder = user.initBuilder(); + builder.send(recipient, cfg.info.amount, token); + const blocks = await builder.build(); + + const options = new TransmitOptions(); + options.setGenerateFeeBlock( + (factoryClient: Keeta.KeetaClient, staple: Keeta.VoteStaple, priority: Keeta.Account[]) => + factoryClient.buildFeeBlock(staple, payer, payer, priority), + ); + const transmitted = await user.transmit(blocks, options); + + const senderAfter = await client.balance(trusted, token); + const payerAfter = await client.balance(payer, token); + const recipientBalance = await client.balance(recipient, token); + + return { + transmitted, + senderDebit: (BigInt(senderBefore) - BigInt(senderAfter)).toString(), + payerDebit: (BigInt(payerBefore) - BigInt(payerAfter)).toString(), + recipientBalance, + }; + }, + { info, recipientSeedHex: FACTORY_RECIPIENT_SEED_HEX, payerSeedHex: PAYER_SEED_HEX, payerFund: '500' }, + ); + + expect(result.transmitted, 'the fee-enforcing node must accept the factory-fee staple').toBe(true); + expect(BigInt(result.senderDebit), 'the sender must be debited the amount alone').toBe(BigInt(info.amount)); + expect(BigInt(result.payerDebit), 'the third-party payer must be debited exactly the fee').toBe( + BigInt(info.fee), + ); + expect(BigInt(result.recipientBalance), 'the recipient must be credited the amount alone').toBe( + BigInt(info.amount), + ); + }); + + for (const probe of FAILING_PROBES) { + test(`${probe.reason} throws code ${probe.code}`, async ({ page }) => { + const code: string = await page.evaluate( + async (cfg: { info: NodeInfo; senderSeedHex: string; senderFund: string; code: string }) => { + const { KeetaClient, UserClient, Account, TransmitOptions } = ( + window as unknown as { keeta: typeof Keeta } + ).keeta; + + const client = new KeetaClient(cfg.info.api).withNetwork(cfg.info.network); + const trusted = Account.fromSeed(cfg.info.trustedSeedHex, 0, 'ed25519'); + const sender = Account.fromSeed(cfg.senderSeedHex, 0, 'ed25519'); + const token = Account.fromPublicKeyString(cfg.info.baseToken); + + await client.send(trusted, sender, cfg.senderFund, token); + const user = UserClient.fromClient(client, sender); + + const builder = user.initBuilder(); + builder.send(trusted, cfg.info.amount, token); + const blocks = await builder.build(); + + // One transmitter per expected code. FEE_REQUIRED goes through + // the bare KeetaClient: a signer-bound UserClient defaults to + // paying its own fee, so the no-factory path never surfaces there. + const throwingFactory = new TransmitOptions(); + throwingFactory.setGenerateFeeBlock(async () => { + throw new Error('payer offline'); + }); + const transmitByCode: Record Promise> = { + FEE_REQUIRED: () => client.transmit(blocks, new TransmitOptions()), + FEE_BLOCK_FACTORY: () => user.transmit(blocks, throwingFactory), + }; + + try { + await transmitByCode[cfg.code](); + return 'NO_THROW'; + } catch (error) { + return (error as { code?: string }).code ?? 'NO_CODE'; + } + }, + { info, senderSeedHex: probe.senderSeedHex, senderFund: '2000', code: probe.code }, + ); + + expect(code, `${probe.reason} must throw code ${probe.code}`).toBe(probe.code); + }); + } +}); diff --git a/keetanetwork-client-wasm/tests/playwright.config.ts b/keetanetwork-client-wasm/tests/playwright.config.ts index 8edff62..83b717d 100644 --- a/keetanetwork-client-wasm/tests/playwright.config.ts +++ b/keetanetwork-client-wasm/tests/playwright.config.ts @@ -1,6 +1,33 @@ +import { execSync } from 'node:child_process'; + import { defineConfig, devices } from '@playwright/test'; -const PORT = 5173; +// Binds both servers at once inside one child process so the two picks +// cannot collide with each other, then prints them space-separated. +const PICK_PORTS_SCRIPT = + 'const net = require("node:net");' + + 'const plain = net.createServer();' + + 'const fee = net.createServer();' + + 'plain.listen(0, () => fee.listen(0, () => {' + + 'console.log(plain.address().port, fee.address().port);' + + 'plain.close();' + + 'fee.close();' + + '}));'; + +// Free ports picked once in the runner process and pinned through the +// environment: workers re-evaluate this module (fee.spec.ts imports it) but +// inherit the runner's environment, so every process agrees on the ports. +function reservedPorts(): number[] { + if (process.env.KEETA_E2E_PORTS === undefined) { + process.env.KEETA_E2E_PORTS = execSync(`node -e '${PICK_PORTS_SCRIPT}'`).toString().trim(); + } + + return process.env.KEETA_E2E_PORTS.split(' ').map(Number); +} + +// The second port serves the fee-enforcing node (fee.spec.ts). +export const [PORT, FEE_PORT] = reservedPorts(); +export const FEE_AMOUNT = '100'; const baseURL = `http://localhost:${PORT}`; export default defineConfig({ @@ -17,12 +44,24 @@ export default defineConfig({ use: { ...devices['Desktop Chrome'] }, }, ], - webServer: { - command: 'node serve.ts', - port: PORT, - reuseExistingServer: false, - timeout: 120_000, - stdout: 'pipe', - stderr: 'pipe', - }, + webServer: [ + { + command: 'node serve.ts', + port: PORT, + env: { PORT: String(PORT) }, + reuseExistingServer: false, + timeout: 120_000, + stdout: 'pipe', + stderr: 'pipe', + }, + { + command: 'node serve.ts', + port: FEE_PORT, + env: { PORT: String(FEE_PORT), FEE: FEE_AMOUNT }, + reuseExistingServer: false, + timeout: 120_000, + stdout: 'pipe', + stderr: 'pipe', + }, + ], }); diff --git a/keetanetwork-client-wasm/tests/serve.ts b/keetanetwork-client-wasm/tests/serve.ts index 0614490..315f8da 100644 --- a/keetanetwork-client-wasm/tests/serve.ts +++ b/keetanetwork-client-wasm/tests/serve.ts @@ -18,6 +18,7 @@ const nodeDist = join(harnessRoot, 'node_modules/@keetanetwork/keetanet-node/dis const staticRoot = resolve(here, '..'); const PORT = Number(process.env.PORT ?? 5173); +const FEE = process.env.FEE ?? ''; const TRUSTED_SEED_HEX = '77'.repeat(32); const MINT_AMOUNT = '1000000000'; const SEND_AMOUNT = '1000'; @@ -39,7 +40,12 @@ interface HarnessResponse { [key: string]: unknown; } -const harness = spawn('node', [harnessScript, nodeDist], { stdio: ['pipe', 'pipe', 'inherit'] }); +const harnessArgs = [harnessScript, nodeDist]; +if (FEE !== '') { + harnessArgs.push(`--fee=${FEE}`); +} + +const harness = spawn('node', harnessArgs, { stdio: ['pipe', 'pipe', 'inherit'] }); const harnessStdout = harness.stdout; const harnessStdin = harness.stdin; if (harnessStdout === null || harnessStdin === null) { @@ -89,6 +95,7 @@ const info = { recipient: ready.representative, trustedSeedHex: TRUSTED_SEED_HEX, amount: SEND_AMOUNT, + fee: FEE, }; const server = createServer(async (request: IncomingMessage, response: ServerResponse) => { diff --git a/keetanetwork-client/src/error.rs b/keetanetwork-client/src/error.rs index ba8adc3..95db6be 100644 --- a/keetanetwork-client/src/error.rs +++ b/keetanetwork-client/src/error.rs @@ -112,6 +112,18 @@ pub enum ClientError { #[snafu(display("node votes require a fee block but no fee-block factory is set"))] FeeRequired, + /// A caller-supplied fee-block factory failed to produce a block. + #[snafu(display("fee-block factory failed"))] + FeeBlockFactory { + /// Underlying factory error. `Send + Sync` on native targets, relaxed + /// on wasm, where JS callback failures are `!Send`/`!Sync`. + #[cfg(not(target_family = "wasm"))] + source: Box, + /// Underlying factory error. + #[cfg(target_family = "wasm")] + source: Box, + }, + /// An account address could not be parsed or derived: a malformed address /// in a node response, or a failed network base-token derivation. #[snafu(display("account parsing or derivation failed"))] @@ -209,6 +221,7 @@ impl ClientError { Self::MissingPublish => "MISSING_PUBLISH", Self::MissingVersion => "MISSING_VERSION", Self::FeeRequired => "FEE_REQUIRED", + Self::FeeBlockFactory { .. } => "FEE_BLOCK_FACTORY", Self::Account { .. } => "ACCOUNT", Self::UnsupportedNetwork => "UNSUPPORTED_NETWORK", Self::NoRepresentatives => "NO_REPRESENTATIVES", diff --git a/keetanetwork-client/tests/e2e.rs b/keetanetwork-client/tests/e2e.rs index 8ba8b04..4c4c59f 100644 --- a/keetanetwork-client/tests/e2e.rs +++ b/keetanetwork-client/tests/e2e.rs @@ -10,7 +10,7 @@ use core::time::Duration; use std::sync::Arc; use keetanetwork_account::{AccountPublicKey, GenericAccount, KeyPairType}; -use keetanetwork_block::testing::generate_ed25519_ref; +use keetanetwork_block::testing::{generate_ed25519_ref, random_ed25519_ref}; use keetanetwork_block::{ AccountRef, AdjustMethod, Amount, BaseFlag, Block, BlockHash, BlockTime, Hashable, ModifyPermissions, ModifyPermissionsPrincipal, Operation, Permissions, SetInfo, @@ -587,14 +587,6 @@ async fn test_transmit_without_signer_when_fee_required_errors() -> Result<(), B Ok(()) } -/// Seed byte for the third-party fee payer in the fee-payer tests. Each test -/// boots its own node, so the seed is shared safely across them. -const FEE_PAYER_SEED_BYTE: u8 = 0x44; - -/// Seed byte for the send recipient in the fee-payer tests. Distinct from -/// the representative so the fee `payTo` credit is not mixed. -const FEE_RECIPIENT_SEED_BYTE: u8 = 0x45; - /// Funding granted to a fee payer, covering several node fees. const PAYER_FUNDING: u64 = FEE_AMOUNT * 10; @@ -604,12 +596,13 @@ fn trusted_fee_options(accounts: &SigningAccounts) -> TransmitOptions { TransmitOptions::default().with_fee_signer(&accounts.trusted) } -/// Fund a fresh ed25519 fee payer from the trusted account. +/// Fund a fresh fee payer from the trusted account. The fee contract accepts +/// any account, so the payer is randomly keyed. async fn funded_payer( client: &KeetaClient, accounts: &SigningAccounts, ) -> Result> { - let payer = generate_ed25519_ref(FEE_PAYER_SEED_BYTE); + let payer = random_ed25519_ref(); let amount = Amount::from(PAYER_FUNDING); let funded = client @@ -669,7 +662,9 @@ async fn assert_third_party_pays_fee( payer: &AccountRef, options: TransmitOptions, ) -> Result<(), Box> { - let recipient = generate_ed25519_ref(FEE_RECIPIENT_SEED_BYTE); + // A fresh random recipient, necessarily distinct from the representative + // the fee is paid to, so the credited balance reflects the send alone. + let recipient = random_ed25519_ref(); let sender_before = client.balance(&*accounts.trusted, &*accounts.token).await?; let payer_before = client.balance(&**payer, &*accounts.token).await?; From 6e64761dc5f7978b447a146de51c367c2dd985d4 Mon Sep 17 00:00:00 2001 From: Tanveer Wahid Date: Thu, 23 Jul 2026 16:10:26 -0700 Subject: [PATCH 8/8] fix(wasm): error handling --- keetanetwork-client-wasm/src/convert.rs | 16 +++++++- keetanetwork-client-wasm/src/options.rs | 24 ++++++++--- keetanetwork-client-wasm/tests/fee.spec.ts | 47 ++++++++++++++++------ 3 files changed, 69 insertions(+), 18 deletions(-) diff --git a/keetanetwork-client-wasm/src/convert.rs b/keetanetwork-client-wasm/src/convert.rs index f791df3..3e82f7a 100644 --- a/keetanetwork-client-wasm/src/convert.rs +++ b/keetanetwork-client-wasm/src/convert.rs @@ -11,6 +11,8 @@ use keetanetwork_client::{ClientError, LedgerSide, VoteBlockHash}; use num_bigint::BigInt; use wasm_bindgen::JsValue; +use crate::options::FactoryFailure; + /// Result whose error is a coded JavaScript `Error` (see module docs). pub type JsResult = Result; @@ -84,8 +86,20 @@ pub fn coded(error: CodedError) -> JsValue { coded_error(&error.code, &error.message) } -/// Convert a [`ClientError`] into a coded JavaScript `Error`. +/// Convert a [`ClientError`] into a coded JavaScript `Error`. A fee-block +/// factory failure that carried a coded JS error re-surfaces that code, so a +/// factory built on client calls keeps its typed failures instead of every +/// one collapsing to `FEE_BLOCK_FACTORY`. pub fn client_error(error: ClientError) -> JsValue { + if let ClientError::FeeBlockFactory { source } = &error { + let carried = source + .downcast_ref::() + .and_then(FactoryFailure::code); + if let Some(code) = carried { + return coded_error(code, &source.to_string()); + } + } + coded(CodedError::from(error)) } diff --git a/keetanetwork-client-wasm/src/options.rs b/keetanetwork-client-wasm/src/options.rs index 6edee41..d910381 100644 --- a/keetanetwork-client-wasm/src/options.rs +++ b/keetanetwork-client-wasm/src/options.rs @@ -107,14 +107,25 @@ async fn generate_via_js( Ok(block.inner()) } -/// The thrown JS value, stringified: a `JsValue` cannot itself cross into the -/// core error chain as a `core::error::Error` source. +/// The thrown JS value, carried across the core error chain: a `JsValue` +/// cannot itself be a `core::error::Error` source, so its stable `code` +/// property (when present) and message are captured instead. #[derive(Debug)] -struct FactoryFailure(String); +pub(crate) struct FactoryFailure { + code: Option, + message: String, +} + +impl FactoryFailure { + /// The `code` property the thrown value carried, if any. + pub(crate) fn code(&self) -> Option<&str> { + self.code.as_deref() + } +} impl fmt::Display for FactoryFailure { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(&self.0) + formatter.write_str(&self.message) } } @@ -122,13 +133,16 @@ impl core::error::Error for FactoryFailure {} /// Project a value thrown by the JS factory onto the client error taxonomy. fn factory_failure(thrown: JsValue) -> ClientError { + let code = js_sys::Reflect::get(&thrown, &JsValue::from_str("code")) + .ok() + .and_then(|value| value.as_string()); let message = thrown .dyn_ref::() .map(|error| String::from(error.message())) .or_else(|| thrown.as_string()) .unwrap_or_else(|| String::from("fee-block factory threw a non-Error value")); - ClientError::FeeBlockFactory { source: Box::new(FactoryFailure(message)) } + ClientError::FeeBlockFactory { source: Box::new(FactoryFailure { code, message }) } } impl TransmitOptions { diff --git a/keetanetwork-client-wasm/tests/fee.spec.ts b/keetanetwork-client-wasm/tests/fee.spec.ts index 77eb541..d026285 100644 --- a/keetanetwork-client-wasm/tests/fee.spec.ts +++ b/keetanetwork-client-wasm/tests/fee.spec.ts @@ -30,10 +30,28 @@ const PAYER_SEED_HEX = '83'.repeat(32); // The probes whose transmit must fail with a typed code. Each gets its own // funded sender so its abandoned temporary vote cannot collide with the -// trusted account's later staples. +// trusted account's later staples. The coded-factory probe proves a coded +// error thrown by the factory keeps its own code instead of collapsing to +// FEE_BLOCK_FACTORY. const FAILING_PROBES = [ - { reason: 'a required fee without a factory', code: 'FEE_REQUIRED', senderSeedHex: '84'.repeat(32) }, - { reason: 'a throwing factory', code: 'FEE_BLOCK_FACTORY', senderSeedHex: '85'.repeat(32) }, + { + id: 'missing-factory', + reason: 'a required fee without a factory', + code: 'FEE_REQUIRED', + senderSeedHex: '84'.repeat(32), + }, + { + id: 'throwing-factory', + reason: 'a throwing factory', + code: 'FEE_BLOCK_FACTORY', + senderSeedHex: '85'.repeat(32), + }, + { + id: 'coded-factory', + reason: 'a factory throwing a coded error', + code: 'FEE_VETOED', + senderSeedHex: '86'.repeat(32), + }, ] as const; test.describe('fee payment surface', () => { @@ -153,7 +171,7 @@ test.describe('fee payment surface', () => { for (const probe of FAILING_PROBES) { test(`${probe.reason} throws code ${probe.code}`, async ({ page }) => { const code: string = await page.evaluate( - async (cfg: { info: NodeInfo; senderSeedHex: string; senderFund: string; code: string }) => { + async (cfg: { info: NodeInfo; senderSeedHex: string; senderFund: string; id: string }) => { const { KeetaClient, UserClient, Account, TransmitOptions } = ( window as unknown as { keeta: typeof Keeta } ).keeta; @@ -170,26 +188,31 @@ test.describe('fee payment surface', () => { builder.send(trusted, cfg.info.amount, token); const blocks = await builder.build(); - // One transmitter per expected code. FEE_REQUIRED goes through - // the bare KeetaClient: a signer-bound UserClient defaults to - // paying its own fee, so the no-factory path never surfaces there. + // One transmitter per probe const throwingFactory = new TransmitOptions(); throwingFactory.setGenerateFeeBlock(async () => { throw new Error('payer offline'); }); - const transmitByCode: Record Promise> = { - FEE_REQUIRED: () => client.transmit(blocks, new TransmitOptions()), - FEE_BLOCK_FACTORY: () => user.transmit(blocks, throwingFactory), + + const codedFactory = new TransmitOptions(); + codedFactory.setGenerateFeeBlock(async () => { + throw Object.assign(new Error('fee vetoed by policy'), { code: 'FEE_VETOED' }); + }); + + const transmitById: Record Promise> = { + 'missing-factory': () => client.transmit(blocks, new TransmitOptions()), + 'throwing-factory': () => user.transmit(blocks, throwingFactory), + 'coded-factory': () => user.transmit(blocks, codedFactory), }; try { - await transmitByCode[cfg.code](); + await transmitById[cfg.id](); return 'NO_THROW'; } catch (error) { return (error as { code?: string }).code ?? 'NO_CODE'; } }, - { info, senderSeedHex: probe.senderSeedHex, senderFund: '2000', code: probe.code }, + { info, senderSeedHex: probe.senderSeedHex, senderFund: '2000', id: probe.id }, ); expect(code, `${probe.reason} must throw code ${probe.code}`).toBe(probe.code);