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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/consensus/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -1267,6 +1267,7 @@ async def exec_transaction(
TransactionStatus.ACCEPTED.value,
TransactionStatus.UNDETERMINED.value,
TransactionStatus.FINALIZED.value,
TransactionStatus.CANCELED.value,
TransactionStatus.LEADER_TIMEOUT.value,
TransactionStatus.VALIDATORS_TIMEOUT.value,
]
Expand Down
25 changes: 25 additions & 0 deletions backend/database_handler/transactions_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -748,6 +748,31 @@ def get_activated_transactions_older_than(self, seconds: int) -> list[dict]:
for transaction in stuck_transactions
]

@staticmethod
def cancel_transaction_if_available(
session: Session, transaction_hash: str
) -> bool:
"""
Cancel a transaction only if it is still available to be cancelled.

Returns:
bool: True when the transaction was cancelled, False otherwise.
"""
result = session.execute(
text(
"""
UPDATE transactions
SET status = CAST('CANCELED' AS transaction_status)
WHERE hash = :hash
AND status IN ('PENDING', 'ACTIVATED')
AND blocked_at IS NULL
"""
),
{"hash": transaction_hash},
)
session.commit()
return result.rowcount > 0

def update_transaction_status(
self,
transaction_hash: str,
Expand Down
127 changes: 127 additions & 0 deletions backend/protocol_rpc/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -684,6 +684,133 @@ def admin_upgrade_contract_code(
}


####### CANCEL TRANSACTION ENDPOINTS #######
def cancel_transaction(
session: Session,
transaction_hash: str,
msg_handler,
signature: str | None = None,
admin_key: str | None = None,
) -> dict:
"""
Cancel a pending or activated transaction. Returns immediately with status.

Access control:
- Local (no env vars): open access
- Hosted/Self-hosted: admin_key allows ANY transaction, signature allows own transactions

Args:
session: Database session
transaction_hash: Hash of the transaction to cancel
msg_handler: Message handler for WebSocket notifications
signature: Hex-encoded signature from tx sender (required in hosted mode unless admin_key)
admin_key: Admin API key for full access to any transaction

Returns:
dict with transaction_hash and status
"""
from backend.database_handler.models import Transactions
from eth_account.messages import encode_defunct
from eth_account import Account
from web3 import Web3
import os

# Validate transaction hash format
if (
not transaction_hash
or not transaction_hash.startswith("0x")
or len(transaction_hash) != 66
):
raise JSONRPCError(
code=-32602,
message="Invalid transaction hash format",
data={},
)

# Look up the transaction
transaction = (
session.query(Transactions).filter_by(hash=transaction_hash).one_or_none()
)
if not transaction:
raise NotFoundError(
message="Transaction not found",
data={"transaction_hash": transaction_hash},
)

is_hosted = os.getenv("VITE_IS_HOSTED") == "true"
admin_api_key = os.getenv("ADMIN_API_KEY")

# Check if authorization is needed (hosted or self-hosted with key configured)
needs_auth = is_hosted or admin_api_key

if needs_auth:
# Option 1: Admin key grants full access to ANY transaction
if admin_api_key and admin_key == admin_api_key:
pass # Authorized - proceed with cancel

# Option 2: Signature from tx sender grants access to own transactions
elif signature:
if not transaction.from_address:
raise JSONRPCError(
code=-32000,
message="Transaction has no sender - only admin key can cancel",
data={},
)

try:
# Message: keccak256("cancel_transaction" + tx_hash_bytes)
# tx_hash is unique, so no nonce needed for replay protection
message_hash = Web3.keccak(
b"cancel_transaction" + Web3.to_bytes(hexstr=transaction_hash)
)
message = encode_defunct(primitive=message_hash)
signer = Account.recover_message(message, signature=signature)

if signer.lower() != transaction.from_address.lower():
raise JSONRPCError(
code=-32000,
message="Only transaction sender can cancel",
data={"signer": signer, "sender": transaction.from_address},
)
except JSONRPCError:
raise
except Exception as e:
raise JSONRPCError(
code=-32000,
message=f"Invalid signature: {e!s}",
data={},
) from e
else:
raise JSONRPCError(
code=-32000,
message="Cancel requires admin key or sender signature",
data={},
)

# Atomic cancel - only succeeds if tx is still pending/activated and not claimed by worker
was_cancelled = TransactionsProcessor.cancel_transaction_if_available(
session, transaction_hash
)

if not was_cancelled:
raise JSONRPCError(
code=-32000,
message="Transaction cannot be cancelled: already being processed or in a terminal state",
data={
"transaction_hash": transaction_hash,
"status": transaction.status.value,
},
)
Comment on lines +795 to +803
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Error data may show a stale status after a failed cancel.

transaction.status is read before cancel_transaction_if_available executes (line 732). If a consensus worker claimed and advanced the transaction in the interim, the error data will display the pre-race status (e.g., still PENDING) rather than the actual terminal state that caused the rejection, which can confuse callers.

🛠️ Proposed fix — re-fetch status on failure
     if not was_cancelled:
+        # Re-query to get the current (post-race) status for an accurate error payload
+        session.refresh(transaction)
         raise JSONRPCError(
             code=-32000,
             message="Transaction cannot be cancelled: already being processed or in a terminal state",
             data={
                 "transaction_hash": transaction_hash,
                 "status": transaction.status.value,
             },
         )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/protocol_rpc/endpoints.py` around lines 795 - 803, The error payload
uses a stale transaction.status value because status was read before calling
cancel_transaction_if_available; after detecting was_cancelled is False you
should re-query the latest transaction state and use that current status in the
JSONRPCError data. Locate the cancel flow around cancel_transaction_if_available
and replace the pre-read transaction.status value with a fresh fetch (using the
same transaction lookup used earlier keyed by transaction_hash) so the
JSONRPCError includes the actual terminal status in its data field
(transaction_hash and the re-fetched transaction.status.value).


# Notify frontend via WebSocket
msg_handler.send_transaction_status_update(transaction_hash, "CANCELED")

return {
"transaction_hash": transaction_hash,
"status": "CANCELED",
}
Comment on lines +687 to +811
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify the number of `cancel_transaction` function definitions in the endpoints file
echo "=== Checking for duplicate cancel_transaction definitions ==="
rg -n "^def cancel_transaction" backend/protocol_rpc/endpoints.py

echo ""
echo "=== Line count and context around lines 687-811 ==="
wc -l backend/protocol_rpc/endpoints.py

Repository: genlayerlabs/genlayer-studio

Length of output: 253


🏁 Script executed:

#!/bin/bash
# Get the full cancel_transaction function and surrounding context
sed -n '685,815p' backend/protocol_rpc/endpoints.py | cat -n

Repository: genlayerlabs/genlayer-studio

Length of output: 5495


🏁 Script executed:

#!/bin/bash
# Check module-level imports of os in the file
head -80 backend/protocol_rpc/endpoints.py | grep -n "^import os\|^from os"

Repository: genlayerlabs/genlayer-studio

Length of output: 84


Add type hint to msg_handler parameter and validate hex characters in transaction hash.

Line 691: msg_handler is missing a type hint. This violates the coding guideline requiring type hints on all Python code. Provide the correct type annotation.

Lines 719–728: Transaction hash validation checks only the format (0x prefix, 66 characters), but does not validate that the remaining 64 characters are valid hexadecimal. Add validation using int(transaction_hash, 16) inside a try-except to reject malformed hashes early.

Additional minor improvements:

  • Line 716: import os is redundant; os is already imported at line 53. Remove the local import.
  • Lines 806, 810: Replace hardcoded "CANCELED" strings with TransactionStatus.CANCELED.value for consistency and maintainability.
  • Line 801: The transaction.status.value in the error response is fetched before the cancel attempt and may be stale if the transaction state changes concurrently. Consider deferring or refreshing.
🧰 Tools
🪛 Ruff (0.15.2)

[warning] 770-774: Abstract raise to an inner function

(TRY301)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/protocol_rpc/endpoints.py` around lines 687 - 811, Add a type
annotation for the msg_handler parameter (e.g., msg_handler: MessageHandler) and
import that MessageHandler type where the module-level imports live; validate
the transaction_hash hex payload by wrapping int(transaction_hash[2:], 16) in a
try/except and raise JSONRPCError on ValueError to reject non-hex characters;
remove the local "import os" and rely on the module-level os import; replace
hardcoded "CANCELED" string usages with TransactionStatus.CANCELED.value (import
TransactionStatus from backend.database_handler.models or the appropriate enum)
and when constructing the error response on failed cancel, refresh or re-query
the Transactions row (or fetch its status after
TransactionsProcessor.cancel_transaction_if_available) before reading
transaction.status.value so the returned status is current.



####### GEN ENDPOINTS #######
async def get_contract_schema(
accounts_manager: AccountsManager,
Expand Down
17 changes: 17 additions & 0 deletions backend/protocol_rpc/rpc_methods.py
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,23 @@ def upgrade_contract_code(
)


@rpc.method("sim_cancelTransaction")
def cancel_transaction(
transaction_hash: str,
signature: str = None,
admin_key: str = None,
session: Session = Depends(get_db_session),
msg_handler=Depends(get_message_handler),
) -> dict:
return impl.cancel_transaction(
session=session,
transaction_hash=transaction_hash,
msg_handler=msg_handler,
signature=signature,
admin_key=admin_key,
)


@rpc.method("sim_getTransactionsForAddress", log_policy=LogPolicy.debug())
def get_transactions_for_address(
address: str,
Expand Down
60 changes: 57 additions & 3 deletions frontend/src/components/Simulator/TransactionItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useTimeAgo } from '@vueuse/core';
import ModalSection from '@/components/Simulator/ModalSection.vue';
import JsonViewer from '@/components/JsonViewer/json-viewer.vue';
import { useUIStore, useNodeStore, useTransactionsStore } from '@/stores';
import { notify } from '@kyvg/vue3-notification';
import {
CheckCircleIcon,
XCircleIcon,
Expand All @@ -18,7 +19,10 @@ import {
b64ToArray,
calldataToUserFriendlyJson,
} from '@/calldata/jsonifier';
import { getRuntimeConfigNumber } from '@/utils/runtimeConfig';
import {
getRuntimeConfigBoolean,
getRuntimeConfigNumber,
} from '@/utils/runtimeConfig';

const uiStore = useUIStore();
const nodeStore = useNodeStore();
Expand Down Expand Up @@ -84,6 +88,37 @@ const handleSetTransactionAppeal = async () => {
};

const isAppealed = computed(() => props.transaction.data.appealed);
const isHosted = getRuntimeConfigBoolean('VITE_IS_HOSTED', false);
const hasSenderAddress = computed(() =>
Boolean(
props.transaction.data?.from_address || props.transaction.data?.sender,
),
);

const canCancel = computed(() => {
const cancellableStatus =
props.transaction.statusName === 'PENDING' ||
props.transaction.statusName === 'ACTIVATED';
const requiresAdminOnly =
isHosted && props.transaction.type === 'upgrade' && !hasSenderAddress.value;
return cancellableStatus && !requiresAdminOnly;
});
const isCancelling = ref(false);
const handleCancelTransaction = async () => {
isCancelling.value = true;
try {
await transactionsStore.cancelTransaction(props.transaction.hash);
} catch (e: any) {
notify({
type: 'error',
title: 'Error cancelling transaction',
text: e?.message ?? 'Unable to cancel transaction',
});
console.error('Error cancelling transaction', e);
} finally {
isCancelling.value = false;
}
};

function prettifyTxData(x: any): any {
const oldResult = x?.consensus_data?.leader_receipt?.[0].result;
Expand Down Expand Up @@ -218,10 +253,28 @@ const badgeColorClass = computed(() => {
transaction.statusName !== 'ACCEPTED' &&
transaction.statusName !== 'UNDETERMINED' &&
transaction.statusName !== 'LEADER_TIMEOUT' &&
transaction.statusName !== 'VALIDATORS_TIMEOUT'
transaction.statusName !== 'VALIDATORS_TIMEOUT' &&
transaction.statusName !== 'CANCELED'
"
/>

<div @click.stop="">
<Btn
v-if="canCancel"
@click="handleCancelTransaction"
tiny
class="!h-[18px] !px-[4px] !py-[1px] !text-[9px] !font-medium"
:data-testid="`cancel-transaction-btn-${transaction.hash}`"
:loading="isCancelling"
:disabled="isCancelling"
>
<div class="flex items-center gap-1">
{{ isCancelling ? 'CANCELLING...' : 'CANCEL' }}
<XCircleIcon class="h-2.5 w-2.5" />
</div>
</Btn>
</div>

<div @click.stop="">
<Btn
v-if="
Expand Down Expand Up @@ -303,7 +356,8 @@ const badgeColorClass = computed(() => {
transaction.statusName !== 'ACCEPTED' &&
transaction.statusName !== 'UNDETERMINED' &&
transaction.statusName !== 'LEADER_TIMEOUT' &&
transaction.statusName !== 'VALIDATORS_TIMEOUT'
transaction.statusName !== 'VALIDATORS_TIMEOUT' &&
transaction.statusName !== 'CANCELED'
"
/>
<TransactionStatusBadge
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/services/IJsonRpcService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,8 @@ export interface IJsonRpcService {
signature?: string,
): Promise<{ transaction_hash: string; message: string }>;
getContractNonce(address: string): Promise<number>;
cancelTransaction(
transactionHash: string,
signature?: string,
): Promise<{ transaction_hash: string; status: string }>;
}
11 changes: 11 additions & 0 deletions frontend/src/services/JsonRpcService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,4 +222,15 @@ export class JsonRpcService implements IJsonRpcService {
'Error getting contract nonce',
) as Promise<number>;
}

async cancelTransaction(
transactionHash: string,
signature?: string,
): Promise<{ transaction_hash: string; status: string }> {
return this.callRpcMethod<{ transaction_hash: string; status: string }>(
'sim_cancelTransaction',
[transactionHash, signature],
'Error cancelling transaction',
) as Promise<{ transaction_hash: string; status: string }>;
}
}
35 changes: 35 additions & 0 deletions frontend/src/stores/transactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,40 @@ export const useTransactionsStore = defineStore('transactionsStore', () => {
});
}

async function cancelTransaction(txHash: `0x${string}`) {
const { useRpcClient } = await import('@/hooks/useRpcClient');
const rpcClient = useRpcClient();

const { useAccountsStore } = await import('@/stores/accounts');
const accountsStore = useAccountsStore();
const account = accountsStore.selectedAccount;
let signature: string | undefined;

if (account) {
const { keccak256, toBytes, concat, stringToBytes } =
await import('viem');
const { privateKeyToAccount } = await import('viem/accounts');

const messageHash = keccak256(
concat([stringToBytes('cancel_transaction'), toBytes(txHash)]),
);

if (account.type === 'local' && account.privateKey) {
const signer = privateKeyToAccount(account.privateKey as `0x${string}`);
signature = await signer.signMessage({
message: { raw: messageHash },
});
} else if (account.type === 'metamask' && window.ethereum) {
signature = await window.ethereum.request({
method: 'personal_sign',
params: [messageHash, account.address],
});
}
}

await rpcClient.cancelTransaction(txHash, signature);
}

function subscribe(topics: string[]) {
topics.forEach((topic) => {
subscriptions.add(topic);
Expand Down Expand Up @@ -138,6 +172,7 @@ export const useTransactionsStore = defineStore('transactionsStore', () => {
updateTransaction,
clearTransactionsForContract,
setTransactionAppeal,
cancelTransaction,
refreshPendingTransactions,
initSubscriptions,
resetStorage,
Expand Down
Loading
Loading