Skip to content
Open
415 changes: 415 additions & 0 deletions src/__tests__/nativeScriptUtils.test.ts

Large diffs are not rendered by default.

234 changes: 228 additions & 6 deletions src/__tests__/signTransaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,34 @@ jest.mock(
{ virtual: true },
);

const applyRateLimitMock = jest.fn<
(req: NextApiRequest, res: NextApiResponse, options?: unknown) => boolean
>();
const enforceBodySizeMock = jest.fn<
(req: NextApiRequest, res: NextApiResponse, maxBytes: number) => boolean
>();

jest.mock(
'@/lib/security/requestGuards',
() => ({
__esModule: true,
applyRateLimit: applyRateLimitMock,
enforceBodySize: enforceBodySizeMock,
}),
{ virtual: true },
);

const getClientIPMock = jest.fn<(req: NextApiRequest) => string>();

jest.mock(
'@/lib/security/rateLimit',
() => ({
__esModule: true,
getClientIP: getClientIPMock,
}),
{ virtual: true },
);

const createCallerMock = jest.fn();

jest.mock(
Expand Down Expand Up @@ -82,6 +110,40 @@ jest.mock(
{ virtual: true },
);

const shouldSubmitMultisigTxMock = jest.fn<
(wallet: unknown, signedAddressesCount: number) => boolean
>();
const submitTxWithScriptRecoveryMock = jest.fn<
(args: { txHex: string; submitter: { submitTx: (txHex: string) => Promise<string> } }) => Promise<{ txHash: string; txHex: string; repaired: boolean }>
>();
const createVkeyWitnessFromHexMock = jest.fn<
(keyHex: string, signatureHex: string) => {
publicKey: MockPublicKey;
signature: MockEd25519Signature;
witness: MockVkeywitness;
keyHashHex: string;
}
>();
const addUniqueVkeyWitnessToTxMock = jest.fn<
(originalTxHex: string, witnessToAdd: MockVkeywitness) => {
txHex: string;
witnessAdded: boolean;
vkeyWitnesses: MockVkeywitnesses;
}
>();

jest.mock(
'@/utils/txSignUtils',
() => ({
__esModule: true,
createVkeyWitnessFromHex: createVkeyWitnessFromHexMock,
addUniqueVkeyWitnessToTx: addUniqueVkeyWitnessToTxMock,
shouldSubmitMultisigTx: shouldSubmitMultisigTxMock,
submitTxWithScriptRecovery: submitTxWithScriptRecoveryMock,
}),
{ virtual: true },
);

const resolvePaymentKeyHashMock = jest.fn<(address: string) => string>();

jest.mock(
Expand Down Expand Up @@ -348,12 +410,19 @@ beforeEach(() => {
dbTransactionUpdateManyMock.mockReset();
getProviderMock.mockReset();
addressToNetworkMock.mockReset();
shouldSubmitMultisigTxMock.mockReset();
submitTxWithScriptRecoveryMock.mockReset();
createVkeyWitnessFromHexMock.mockReset();
addUniqueVkeyWitnessToTxMock.mockReset();
resolvePaymentKeyHashMock.mockReset();
calculateTxHashMock.mockReset();
corsMock.mockReset();
addCorsCacheBustingHeadersMock.mockReset();
createCallerMock.mockReset();
verifyJwtMock.mockReset();
applyRateLimitMock.mockReset();
enforceBodySizeMock.mockReset();
getClientIPMock.mockReset();

corsMock.mockResolvedValue(undefined);
addCorsCacheBustingHeadersMock.mockImplementation(() => {
Expand All @@ -362,6 +431,57 @@ beforeEach(() => {
calculateTxHashMock.mockReturnValue('deadbeef');
resolvePaymentKeyHashMock.mockReturnValue(witnessKeyHashHex);
addressToNetworkMock.mockReturnValue(0);
applyRateLimitMock.mockReturnValue(true);
enforceBodySizeMock.mockReturnValue(true);
getClientIPMock.mockReturnValue('127.0.0.1');
shouldSubmitMultisigTxMock.mockReturnValue(true);
createVkeyWitnessFromHexMock.mockImplementation((keyHex, signatureHex) => {
const publicKey = MockPublicKey.from_hex(keyHex);
const signature = MockEd25519Signature.from_hex(signatureHex);
const witness = MockVkeywitness.new(MockVkey.new(publicKey), signature);
return {
publicKey,
signature,
witness,
keyHashHex: witnessKeyHashHex,
};
});
addUniqueVkeyWitnessToTxMock.mockImplementation((originalTxHex, witnessToAdd) => {
const mergedWitnesses = MockVkeywitnesses.from_bytes();
const incomingKeyHash = Buffer.from(
witnessToAdd.vkey().public_key().hash().to_bytes(),
).toString('hex').toLowerCase();

const existingWitnessCount = mergedWitnesses.len();
for (let i = 0; i < existingWitnessCount; i++) {
const existingWitness = mergedWitnesses.get(i);
const existingKeyHash = Buffer.from(
existingWitness.vkey().public_key().hash().to_bytes(),
).toString('hex').toLowerCase();

if (existingKeyHash === incomingKeyHash) {
return {
txHex: originalTxHex,
witnessAdded: false,
vkeyWitnesses: mergedWitnesses,
};
}
}

mergedWitnesses.add(witnessToAdd);
mergedWitnesses.to_bytes();

return {
txHex: 'updated-tx-hex',
witnessAdded: true,
vkeyWitnesses: mergedWitnesses,
};
});
submitTxWithScriptRecoveryMock.mockImplementation(async ({ txHex, submitter }) => ({
txHash: await submitter.submitTx(txHex),
txHex,
repaired: false,
}));

createCallerMock.mockReturnValue({
wallet: { getWallet: walletGetWalletMock },
Expand Down Expand Up @@ -445,13 +565,15 @@ describe('signTransaction API route', () => {
expect(addCorsCacheBustingHeadersMock).toHaveBeenCalledWith(res);
expect(corsMock).toHaveBeenCalledWith(req, res);
expect(verifyJwtMock).toHaveBeenCalledWith('valid-token');
expect(createCallerMock).toHaveBeenCalledWith({
db: dbMock,
session: expect.objectContaining({
user: { id: address },
expires: expect.any(String),
expect(createCallerMock).toHaveBeenCalledWith(
expect.objectContaining({
db: dbMock,
session: expect.objectContaining({
user: { id: address },
expires: expect.any(String),
}),
}),
});
);
expect(walletGetWalletMock).toHaveBeenCalledWith({ walletId, address });
expect(dbTransactionFindUniqueMock).toHaveBeenNthCalledWith(1, {
where: { id: transactionId },
Expand Down Expand Up @@ -694,5 +816,105 @@ describe('signTransaction API route', () => {
expect(dbTransactionUpdateManyMock).not.toHaveBeenCalled();
});

it('persists repaired tx hex when script recovery succeeds', async () => {
const address = 'addr_test1qprecoverysuccess';
const walletId = 'wallet-id-recovery';
const transactionId = 'transaction-id-recovery';
const signatureHex = 'aa'.repeat(64);
const keyHex = 'bb'.repeat(64);

verifyJwtMock.mockReturnValue({ address });

walletGetWalletMock.mockResolvedValue({
id: walletId,
type: 'atLeast',
numRequiredSigners: 1,
signersAddresses: [address],
});

const transactionRecord = {
id: transactionId,
walletId,
state: 0,
signedAddresses: [] as string[],
rejectedAddresses: [] as string[],
txCbor: 'stored-tx-hex',
txHash: null as string | null,
txJson: '{}',
};

const updatedTransaction = {
...transactionRecord,
signedAddresses: [address],
txCbor: 'repaired-tx-hex',
state: 1,
txHash: 'recovered-hash',
txJson: '{"multisig":{"state":1}}',
};

dbTransactionFindUniqueMock
.mockResolvedValueOnce(transactionRecord)
.mockResolvedValueOnce(updatedTransaction);

dbTransactionUpdateManyMock.mockResolvedValue({ count: 1 });

const submitTxMock = jest.fn<(txHex: string) => Promise<string>>();
submitTxMock.mockResolvedValue('should-not-be-used-directly');
getProviderMock.mockReturnValue({ submitTx: submitTxMock });

submitTxWithScriptRecoveryMock.mockResolvedValueOnce({
txHash: 'recovered-hash',
txHex: 'repaired-tx-hex',
repaired: true,
});

const req = {
method: 'POST',
headers: { authorization: 'Bearer valid-token' },
body: {
walletId,
transactionId,
address,
signature: signatureHex,
key: keyHex,
},
} as unknown as NextApiRequest;

const res = createMockResponse();

await handler(req, res);

expect(submitTxWithScriptRecoveryMock).toHaveBeenCalledWith(
expect.objectContaining({
txHex: 'updated-tx-hex',
submitter: expect.objectContaining({
submitTx: expect.any(Function),
}),
}),
);
expect(dbTransactionUpdateManyMock).toHaveBeenCalledWith({
where: {
id: transactionId,
signedAddresses: { equals: [] },
rejectedAddresses: { equals: [] },
txCbor: 'stored-tx-hex',
txJson: '{}',
},
data: expect.objectContaining({
signedAddresses: { set: [address] },
rejectedAddresses: { set: [] },
txCbor: 'repaired-tx-hex',
state: 1,
txHash: 'recovered-hash',
}),
});
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith({
transaction: updatedTransaction,
submitted: true,
txHash: 'recovered-hash',
});
});

});

2 changes: 1 addition & 1 deletion src/components/common/overall-layout/proxy-data-loader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export default function ProxyDataLoader() {
// Update store when API data changes
useEffect(() => {
if (apiProxies && appWallet?.id) {
const proxyData = apiProxies.map(proxy => ({
const proxyData = apiProxies.map((proxy: NonNullable<typeof apiProxies>[number]) => ({
id: proxy.id,
proxyAddress: proxy.proxyAddress,
authTokenId: proxy.authTokenId,
Expand Down
23 changes: 21 additions & 2 deletions src/components/multisig/proxy/ProxyControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,27 @@ export default function ProxyControl() {
{ enabled: !!appWallet?.id }
);

// Use store proxies if available, otherwise fall back to API proxies
const proxies = useMemo(() => storeProxies.length > 0 ? storeProxies : (apiProxies ?? []), [storeProxies, apiProxies]);
// Merge API proxies with store-enriched data.
// API is source of truth for membership; store provides enriched fields (balance, drepInfo, etc).
const proxies = useMemo(() => {
const apiList = apiProxies ?? [];

if (apiList.length === 0) {
return storeProxies;
}

const storeById = new Map(storeProxies.map((proxy) => [proxy.id, proxy]));
const merged = apiList.map((proxy: NonNullable<typeof apiProxies>[number]) => {
const enriched = storeById.get(proxy.id);
return enriched ? { ...enriched, ...proxy } : proxy;
});

// Keep any locally cached proxies that have not reached API yet.
const apiIds = new Set(apiList.map((proxy: NonNullable<typeof apiProxies>[number]) => proxy.id));
const storeOnly = storeProxies.filter((proxy) => !apiIds.has(proxy.id));

return [...merged, ...storeOnly];
}, [storeProxies, apiProxies]);
const proxiesLoading = storeLoading || apiLoading;

const { mutateAsync: createProxy } = api.proxy.createProxy.useMutation({
Expand Down
3 changes: 2 additions & 1 deletion src/components/multisig/proxy/offchain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
} from "@meshsdk/core";
import type { UTxO, MeshTxBuilder } from "@meshsdk/core";
// import { parseDatumCbor } from "@meshsdk/core-cst";
import { DREP_DEPOSIT_STRING } from "@/utils/protocol-deposit-constants";

import { MeshTxInitiator } from "./common";
import type { MeshTxInitiatorInput } from "./common";
Expand Down Expand Up @@ -501,7 +502,7 @@ export class MeshProxyContract extends MeshTxInitiator {
anchorDataHash: anchorHash!,
});
} else if (action === "deregister") {
txHex.drepDeregistrationCertificate(drepId, "500000000");
txHex.drepDeregistrationCertificate(drepId, DREP_DEPOSIT_STRING);
} else if (action === "update") {
txHex.drepUpdateCertificate(drepId, {
anchorUrl: anchorUrl!,
Expand Down
13 changes: 9 additions & 4 deletions src/components/pages/homepage/wallets/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,10 +103,15 @@ export default function PageWallets() {
},
);

// Filter wallets for balance fetching (only non-archived or all if showing archived)
const walletsForBalance = wallets?.filter(
(wallet) => showArchived || !wallet.isArchived,
) as Wallet[] | undefined;
// Keep a stable wallets array for balance fetching to avoid restarting the queue
// on unrelated rerenders.
const walletsForBalance = useMemo(
() =>
wallets?.filter((wallet) => showArchived || !wallet.isArchived) as
| Wallet[]
| undefined,
[wallets, showArchived],
);

// Fetch balances with rate limiting
const { balances, loadingStates } = useWalletBalances(walletsForBalance);
Expand Down
3 changes: 2 additions & 1 deletion src/components/pages/wallet/governance/drep/retire.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { MeshProxyContract } from "@/components/multisig/proxy/offchain";
import { api } from "@/utils/api";
import { useCallback } from "react";
import { useToast } from "@/hooks/use-toast";
import { DREP_DEPOSIT_STRING } from "@/utils/protocol-deposit-constants";

export default function Retire({ appWallet, manualUtxos }: { appWallet: Wallet; manualUtxos: UTxO[] }) {
const network = useSiteStore((state) => state.network);
Expand Down Expand Up @@ -250,7 +251,7 @@ export default function Retire({ appWallet, manualUtxos }: { appWallet: Wallet;
txBuilder
.txInScript(scriptCbor)
.changeAddress(changeAddress)
.drepDeregistrationCertificate(dRepId, "500000000");
.drepDeregistrationCertificate(dRepId, DREP_DEPOSIT_STRING);

// Only add certificateScript if it's different from the spending script
// to avoid "extraneous scripts" error
Expand Down
Loading
Loading