Skip to content

Commit 95d054f

Browse files
authored
Merge pull request #206 from MeshJS/feature/hierarchical-native-script-support
Feature/hierarchical-native-script-support
2 parents d7e03ee + ce4b6f4 commit 95d054f

File tree

27 files changed

+2540
-664
lines changed

27 files changed

+2540
-664
lines changed

src/__tests__/nativeScriptUtils.test.ts

Lines changed: 415 additions & 0 deletions
Large diffs are not rendered by default.

src/__tests__/signTransaction.test.ts

Lines changed: 228 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,34 @@ jest.mock(
2525
{ virtual: true },
2626
);
2727

28+
const applyRateLimitMock = jest.fn<
29+
(req: NextApiRequest, res: NextApiResponse, options?: unknown) => boolean
30+
>();
31+
const enforceBodySizeMock = jest.fn<
32+
(req: NextApiRequest, res: NextApiResponse, maxBytes: number) => boolean
33+
>();
34+
35+
jest.mock(
36+
'@/lib/security/requestGuards',
37+
() => ({
38+
__esModule: true,
39+
applyRateLimit: applyRateLimitMock,
40+
enforceBodySize: enforceBodySizeMock,
41+
}),
42+
{ virtual: true },
43+
);
44+
45+
const getClientIPMock = jest.fn<(req: NextApiRequest) => string>();
46+
47+
jest.mock(
48+
'@/lib/security/rateLimit',
49+
() => ({
50+
__esModule: true,
51+
getClientIP: getClientIPMock,
52+
}),
53+
{ virtual: true },
54+
);
55+
2856
const createCallerMock = jest.fn();
2957

3058
jest.mock(
@@ -82,6 +110,40 @@ jest.mock(
82110
{ virtual: true },
83111
);
84112

113+
const shouldSubmitMultisigTxMock = jest.fn<
114+
(wallet: unknown, signedAddressesCount: number) => boolean
115+
>();
116+
const submitTxWithScriptRecoveryMock = jest.fn<
117+
(args: { txHex: string; submitter: { submitTx: (txHex: string) => Promise<string> } }) => Promise<{ txHash: string; txHex: string; repaired: boolean }>
118+
>();
119+
const createVkeyWitnessFromHexMock = jest.fn<
120+
(keyHex: string, signatureHex: string) => {
121+
publicKey: MockPublicKey;
122+
signature: MockEd25519Signature;
123+
witness: MockVkeywitness;
124+
keyHashHex: string;
125+
}
126+
>();
127+
const addUniqueVkeyWitnessToTxMock = jest.fn<
128+
(originalTxHex: string, witnessToAdd: MockVkeywitness) => {
129+
txHex: string;
130+
witnessAdded: boolean;
131+
vkeyWitnesses: MockVkeywitnesses;
132+
}
133+
>();
134+
135+
jest.mock(
136+
'@/utils/txSignUtils',
137+
() => ({
138+
__esModule: true,
139+
createVkeyWitnessFromHex: createVkeyWitnessFromHexMock,
140+
addUniqueVkeyWitnessToTx: addUniqueVkeyWitnessToTxMock,
141+
shouldSubmitMultisigTx: shouldSubmitMultisigTxMock,
142+
submitTxWithScriptRecovery: submitTxWithScriptRecoveryMock,
143+
}),
144+
{ virtual: true },
145+
);
146+
85147
const resolvePaymentKeyHashMock = jest.fn<(address: string) => string>();
86148

87149
jest.mock(
@@ -348,12 +410,19 @@ beforeEach(() => {
348410
dbTransactionUpdateManyMock.mockReset();
349411
getProviderMock.mockReset();
350412
addressToNetworkMock.mockReset();
413+
shouldSubmitMultisigTxMock.mockReset();
414+
submitTxWithScriptRecoveryMock.mockReset();
415+
createVkeyWitnessFromHexMock.mockReset();
416+
addUniqueVkeyWitnessToTxMock.mockReset();
351417
resolvePaymentKeyHashMock.mockReset();
352418
calculateTxHashMock.mockReset();
353419
corsMock.mockReset();
354420
addCorsCacheBustingHeadersMock.mockReset();
355421
createCallerMock.mockReset();
356422
verifyJwtMock.mockReset();
423+
applyRateLimitMock.mockReset();
424+
enforceBodySizeMock.mockReset();
425+
getClientIPMock.mockReset();
357426

358427
corsMock.mockResolvedValue(undefined);
359428
addCorsCacheBustingHeadersMock.mockImplementation(() => {
@@ -362,6 +431,57 @@ beforeEach(() => {
362431
calculateTxHashMock.mockReturnValue('deadbeef');
363432
resolvePaymentKeyHashMock.mockReturnValue(witnessKeyHashHex);
364433
addressToNetworkMock.mockReturnValue(0);
434+
applyRateLimitMock.mockReturnValue(true);
435+
enforceBodySizeMock.mockReturnValue(true);
436+
getClientIPMock.mockReturnValue('127.0.0.1');
437+
shouldSubmitMultisigTxMock.mockReturnValue(true);
438+
createVkeyWitnessFromHexMock.mockImplementation((keyHex, signatureHex) => {
439+
const publicKey = MockPublicKey.from_hex(keyHex);
440+
const signature = MockEd25519Signature.from_hex(signatureHex);
441+
const witness = MockVkeywitness.new(MockVkey.new(publicKey), signature);
442+
return {
443+
publicKey,
444+
signature,
445+
witness,
446+
keyHashHex: witnessKeyHashHex,
447+
};
448+
});
449+
addUniqueVkeyWitnessToTxMock.mockImplementation((originalTxHex, witnessToAdd) => {
450+
const mergedWitnesses = MockVkeywitnesses.from_bytes();
451+
const incomingKeyHash = Buffer.from(
452+
witnessToAdd.vkey().public_key().hash().to_bytes(),
453+
).toString('hex').toLowerCase();
454+
455+
const existingWitnessCount = mergedWitnesses.len();
456+
for (let i = 0; i < existingWitnessCount; i++) {
457+
const existingWitness = mergedWitnesses.get(i);
458+
const existingKeyHash = Buffer.from(
459+
existingWitness.vkey().public_key().hash().to_bytes(),
460+
).toString('hex').toLowerCase();
461+
462+
if (existingKeyHash === incomingKeyHash) {
463+
return {
464+
txHex: originalTxHex,
465+
witnessAdded: false,
466+
vkeyWitnesses: mergedWitnesses,
467+
};
468+
}
469+
}
470+
471+
mergedWitnesses.add(witnessToAdd);
472+
mergedWitnesses.to_bytes();
473+
474+
return {
475+
txHex: 'updated-tx-hex',
476+
witnessAdded: true,
477+
vkeyWitnesses: mergedWitnesses,
478+
};
479+
});
480+
submitTxWithScriptRecoveryMock.mockImplementation(async ({ txHex, submitter }) => ({
481+
txHash: await submitter.submitTx(txHex),
482+
txHex,
483+
repaired: false,
484+
}));
365485

366486
createCallerMock.mockReturnValue({
367487
wallet: { getWallet: walletGetWalletMock },
@@ -445,13 +565,15 @@ describe('signTransaction API route', () => {
445565
expect(addCorsCacheBustingHeadersMock).toHaveBeenCalledWith(res);
446566
expect(corsMock).toHaveBeenCalledWith(req, res);
447567
expect(verifyJwtMock).toHaveBeenCalledWith('valid-token');
448-
expect(createCallerMock).toHaveBeenCalledWith({
449-
db: dbMock,
450-
session: expect.objectContaining({
451-
user: { id: address },
452-
expires: expect.any(String),
568+
expect(createCallerMock).toHaveBeenCalledWith(
569+
expect.objectContaining({
570+
db: dbMock,
571+
session: expect.objectContaining({
572+
user: { id: address },
573+
expires: expect.any(String),
574+
}),
453575
}),
454-
});
576+
);
455577
expect(walletGetWalletMock).toHaveBeenCalledWith({ walletId, address });
456578
expect(dbTransactionFindUniqueMock).toHaveBeenNthCalledWith(1, {
457579
where: { id: transactionId },
@@ -694,5 +816,105 @@ describe('signTransaction API route', () => {
694816
expect(dbTransactionUpdateManyMock).not.toHaveBeenCalled();
695817
});
696818

819+
it('persists repaired tx hex when script recovery succeeds', async () => {
820+
const address = 'addr_test1qprecoverysuccess';
821+
const walletId = 'wallet-id-recovery';
822+
const transactionId = 'transaction-id-recovery';
823+
const signatureHex = 'aa'.repeat(64);
824+
const keyHex = 'bb'.repeat(64);
825+
826+
verifyJwtMock.mockReturnValue({ address });
827+
828+
walletGetWalletMock.mockResolvedValue({
829+
id: walletId,
830+
type: 'atLeast',
831+
numRequiredSigners: 1,
832+
signersAddresses: [address],
833+
});
834+
835+
const transactionRecord = {
836+
id: transactionId,
837+
walletId,
838+
state: 0,
839+
signedAddresses: [] as string[],
840+
rejectedAddresses: [] as string[],
841+
txCbor: 'stored-tx-hex',
842+
txHash: null as string | null,
843+
txJson: '{}',
844+
};
845+
846+
const updatedTransaction = {
847+
...transactionRecord,
848+
signedAddresses: [address],
849+
txCbor: 'repaired-tx-hex',
850+
state: 1,
851+
txHash: 'recovered-hash',
852+
txJson: '{"multisig":{"state":1}}',
853+
};
854+
855+
dbTransactionFindUniqueMock
856+
.mockResolvedValueOnce(transactionRecord)
857+
.mockResolvedValueOnce(updatedTransaction);
858+
859+
dbTransactionUpdateManyMock.mockResolvedValue({ count: 1 });
860+
861+
const submitTxMock = jest.fn<(txHex: string) => Promise<string>>();
862+
submitTxMock.mockResolvedValue('should-not-be-used-directly');
863+
getProviderMock.mockReturnValue({ submitTx: submitTxMock });
864+
865+
submitTxWithScriptRecoveryMock.mockResolvedValueOnce({
866+
txHash: 'recovered-hash',
867+
txHex: 'repaired-tx-hex',
868+
repaired: true,
869+
});
870+
871+
const req = {
872+
method: 'POST',
873+
headers: { authorization: 'Bearer valid-token' },
874+
body: {
875+
walletId,
876+
transactionId,
877+
address,
878+
signature: signatureHex,
879+
key: keyHex,
880+
},
881+
} as unknown as NextApiRequest;
882+
883+
const res = createMockResponse();
884+
885+
await handler(req, res);
886+
887+
expect(submitTxWithScriptRecoveryMock).toHaveBeenCalledWith(
888+
expect.objectContaining({
889+
txHex: 'updated-tx-hex',
890+
submitter: expect.objectContaining({
891+
submitTx: expect.any(Function),
892+
}),
893+
}),
894+
);
895+
expect(dbTransactionUpdateManyMock).toHaveBeenCalledWith({
896+
where: {
897+
id: transactionId,
898+
signedAddresses: { equals: [] },
899+
rejectedAddresses: { equals: [] },
900+
txCbor: 'stored-tx-hex',
901+
txJson: '{}',
902+
},
903+
data: expect.objectContaining({
904+
signedAddresses: { set: [address] },
905+
rejectedAddresses: { set: [] },
906+
txCbor: 'repaired-tx-hex',
907+
state: 1,
908+
txHash: 'recovered-hash',
909+
}),
910+
});
911+
expect(res.status).toHaveBeenCalledWith(200);
912+
expect(res.json).toHaveBeenCalledWith({
913+
transaction: updatedTransaction,
914+
submitted: true,
915+
txHash: 'recovered-hash',
916+
});
917+
});
918+
697919
});
698920

src/components/common/overall-layout/proxy-data-loader.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ export default function ProxyDataLoader() {
3434
// Update store when API data changes
3535
useEffect(() => {
3636
if (apiProxies && appWallet?.id) {
37-
const proxyData = apiProxies.map(proxy => ({
37+
const proxyData = apiProxies.map((proxy: NonNullable<typeof apiProxies>[number]) => ({
3838
id: proxy.id,
3939
proxyAddress: proxy.proxyAddress,
4040
authTokenId: proxy.authTokenId,

src/components/multisig/proxy/ProxyControl.tsx

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,27 @@ export default function ProxyControl() {
8484
{ enabled: !!appWallet?.id }
8585
);
8686

87-
// Use store proxies if available, otherwise fall back to API proxies
88-
const proxies = useMemo(() => storeProxies.length > 0 ? storeProxies : (apiProxies ?? []), [storeProxies, apiProxies]);
87+
// Merge API proxies with store-enriched data.
88+
// API is source of truth for membership; store provides enriched fields (balance, drepInfo, etc).
89+
const proxies = useMemo(() => {
90+
const apiList = apiProxies ?? [];
91+
92+
if (apiList.length === 0) {
93+
return storeProxies;
94+
}
95+
96+
const storeById = new Map(storeProxies.map((proxy) => [proxy.id, proxy]));
97+
const merged = apiList.map((proxy: NonNullable<typeof apiProxies>[number]) => {
98+
const enriched = storeById.get(proxy.id);
99+
return enriched ? { ...enriched, ...proxy } : proxy;
100+
});
101+
102+
// Keep any locally cached proxies that have not reached API yet.
103+
const apiIds = new Set(apiList.map((proxy: NonNullable<typeof apiProxies>[number]) => proxy.id));
104+
const storeOnly = storeProxies.filter((proxy) => !apiIds.has(proxy.id));
105+
106+
return [...merged, ...storeOnly];
107+
}, [storeProxies, apiProxies]);
89108
const proxiesLoading = storeLoading || apiLoading;
90109

91110
const { mutateAsync: createProxy } = api.proxy.createProxy.useMutation({

src/components/multisig/proxy/offchain.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
} from "@meshsdk/core";
88
import type { UTxO, MeshTxBuilder } from "@meshsdk/core";
99
// import { parseDatumCbor } from "@meshsdk/core-cst";
10+
import { DREP_DEPOSIT_STRING } from "@/utils/protocol-deposit-constants";
1011

1112
import { MeshTxInitiator } from "./common";
1213
import type { MeshTxInitiatorInput } from "./common";
@@ -501,7 +502,7 @@ export class MeshProxyContract extends MeshTxInitiator {
501502
anchorDataHash: anchorHash!,
502503
});
503504
} else if (action === "deregister") {
504-
txHex.drepDeregistrationCertificate(drepId, "500000000");
505+
txHex.drepDeregistrationCertificate(drepId, DREP_DEPOSIT_STRING);
505506
} else if (action === "update") {
506507
txHex.drepUpdateCertificate(drepId, {
507508
anchorUrl: anchorUrl!,

src/components/pages/homepage/wallets/index.tsx

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -103,10 +103,15 @@ export default function PageWallets() {
103103
},
104104
);
105105

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

111116
// Fetch balances with rate limiting
112117
const { balances, loadingStates } = useWalletBalances(walletsForBalance);

src/components/pages/wallet/governance/drep/retire.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { MeshProxyContract } from "@/components/multisig/proxy/offchain";
1414
import { api } from "@/utils/api";
1515
import { useCallback } from "react";
1616
import { useToast } from "@/hooks/use-toast";
17+
import { DREP_DEPOSIT_STRING } from "@/utils/protocol-deposit-constants";
1718

1819
export default function Retire({ appWallet, manualUtxos }: { appWallet: Wallet; manualUtxos: UTxO[] }) {
1920
const network = useSiteStore((state) => state.network);
@@ -250,7 +251,7 @@ export default function Retire({ appWallet, manualUtxos }: { appWallet: Wallet;
250251
txBuilder
251252
.txInScript(scriptCbor)
252253
.changeAddress(changeAddress)
253-
.drepDeregistrationCertificate(dRepId, "500000000");
254+
.drepDeregistrationCertificate(dRepId, DREP_DEPOSIT_STRING);
254255

255256
// Only add certificateScript if it's different from the spending script
256257
// to avoid "extraneous scripts" error

0 commit comments

Comments
 (0)