Skip to content

Commit 4d55a15

Browse files
committed
feat(express): add derivedFromParentWithSeed for isWalletAddress
Ticket: WP-7522
1 parent 7719e86 commit 4d55a15

2 files changed

Lines changed: 225 additions & 0 deletions

File tree

modules/express/src/typedRoutes/api/v2/isWalletAddress.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,12 @@ export const IsWalletAddressBody = {
8181
format: optional(t.string),
8282
/** Root address for coins that use root address */
8383
rootAddress: optional(t.string),
84+
/**
85+
* Optional seed value from user keychain's derivedFromParentWithSeed field.
86+
* For SMC (Self-Managed Cold) TSS wallets, this is used to compute the derivation prefix.
87+
* This allows reusing the same parent keys to create multiple wallets with different seeds.
88+
*/
89+
derivedFromParentWithSeed: optional(t.string),
8490
} as const;
8591

8692
/**
@@ -103,6 +109,9 @@ export const IsWalletAddressResponse = {
103109
*
104110
* To verify a baseAddress, set the `baseAddress` and `address` to the base address of the wallet.
105111
*
112+
* For SMC (Self-Managed Cold) TSS wallets, include the `derivedFromParentWithSeed` parameter
113+
* to properly verify addresses for wallets that were created by reusing parent keys with a derivation seed.
114+
*
106115
* Due to architecture limitations, forwarder version 0 addresses cannot be verified and will return `true` without verification.
107116
* Verifying custodial wallet addresses is not supported.
108117
*

modules/express/test/unit/typedRoutes/isWalletAddress.ts

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,42 @@ describe('IsWalletAddress codec tests', function () {
193193
assert.strictEqual(decoded.format, 'hex');
194194
});
195195

196+
it('should validate body with derivedFromParentWithSeed for SMC wallets', function () {
197+
const validBody = {
198+
address: '0xa33f0975f53cdcfcc0cb564d25fb5be03b0651cf',
199+
baseAddress: '0xc012041dac143a59fa491db3a2b67b69bd78b685',
200+
keychains: [
201+
{
202+
pub: 'user_pub',
203+
commonKeychain:
204+
'033b02aac4f038fef5118350b77d302ec6202931ca2e7122aad88994ffefcbc70a6069e662436236abb1619195232c41580204cb202c22357ed8f53e69eac5c69e',
205+
},
206+
{
207+
pub: 'backup_pub',
208+
commonKeychain:
209+
'033b02aac4f038fef5118350b77d302ec6202931ca2e7122aad88994ffefcbc70a6069e662436236abb1619195232c41580204cb202c22357ed8f53e69eac5c69e',
210+
},
211+
{
212+
pub: 'bitgo_pub',
213+
commonKeychain:
214+
'033b02aac4f038fef5118350b77d302ec6202931ca2e7122aad88994ffefcbc70a6069e662436236abb1619195232c41580204cb202c22357ed8f53e69eac5c69e',
215+
},
216+
],
217+
walletVersion: 6,
218+
index: 7,
219+
coinSpecific: {
220+
forwarderVersion: 5,
221+
feeAddress: '0xb1e725186990b86ca8efed08a3ccda9c9f400f09',
222+
},
223+
derivedFromParentWithSeed: 'my-unique-smc-seed-123',
224+
};
225+
226+
const decoded = assertDecode(t.type(IsWalletAddressBody), validBody);
227+
assert.strictEqual(decoded.address, validBody.address);
228+
assert.strictEqual(decoded.derivedFromParentWithSeed, 'my-unique-smc-seed-123');
229+
assert.strictEqual(decoded.walletVersion, 6);
230+
});
231+
196232
it('should reject body with missing address', function () {
197233
const invalidBody = {
198234
keychains: [{ pub: 'xpub1...' }],
@@ -255,6 +291,18 @@ describe('IsWalletAddress codec tests', function () {
255291
const decodedString = assertDecode(t.type(IsWalletAddressBody), validBodyWithString);
256292
assert.strictEqual(decodedString.index, '7');
257293
});
294+
295+
it('should reject body with invalid derivedFromParentWithSeed type', function () {
296+
const invalidBody = {
297+
address: '0x6069a4baf2360bf67a6d02a7fc43d8f3910016ae',
298+
keychains: [{ pub: 'xpub1...' }],
299+
derivedFromParentWithSeed: 12345, // Should be string, not number
300+
};
301+
302+
assert.throws(() => {
303+
assertDecode(t.type(IsWalletAddressBody), invalidBody);
304+
});
305+
});
258306
});
259307

260308
describe('IsWalletAddressResponse', function () {
@@ -640,6 +688,157 @@ describe('IsWalletAddress codec tests', function () {
640688
});
641689
});
642690

691+
describe('SMC (Self-Managed Cold) TSS Wallet Address Verification', function () {
692+
const commonKeychain =
693+
'033b02aac4f038fef5118350b77d302ec6202931ca2e7122aad88994ffefcbc70a6069e662436236abb1619195232c41580204cb202c22357ed8f53e69eac5c69e';
694+
695+
it('should verify SMC wallet address with derivedFromParentWithSeed', async function () {
696+
const requestBody = {
697+
address: '0xa33f0975f53cdcfcc0cb564d25fb5be03b0651cf',
698+
baseAddress: '0xc012041dac143a59fa491db3a2b67b69bd78b685',
699+
coinSpecific: {
700+
forwarderVersion: 5,
701+
feeAddress: '0xb1e725186990b86ca8efed08a3ccda9c9f400f09',
702+
},
703+
keychains: [
704+
{ pub: 'user_pub', commonKeychain },
705+
{ pub: 'backup_pub', commonKeychain },
706+
{ pub: 'bitgo_pub', commonKeychain },
707+
],
708+
index: 7,
709+
walletVersion: 6,
710+
derivedFromParentWithSeed: 'my-unique-smc-seed-abc123',
711+
};
712+
713+
const isWalletAddressStub = sinon.stub().resolves(true);
714+
const mockWallet = {
715+
baseCoin: {
716+
isWalletAddress: isWalletAddressStub,
717+
},
718+
};
719+
const walletsGetStub = sinon.stub().resolves(mockWallet);
720+
const mockWallets = {
721+
get: walletsGetStub,
722+
};
723+
const mockCoin = {
724+
wallets: sinon.stub().returns(mockWallets),
725+
};
726+
sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any);
727+
728+
const result = await agent
729+
.post('/api/v2/hteth/wallet/test-wallet-id/iswalletaddress')
730+
.set('Authorization', 'Bearer test_access_token_12345')
731+
.set('Content-Type', 'application/json')
732+
.send(requestBody);
733+
734+
assert.strictEqual(result.status, 200);
735+
assert.strictEqual(result.body, true);
736+
737+
// Verify that the derivedFromParentWithSeed was passed to isWalletAddress
738+
sinon.assert.calledOnce(isWalletAddressStub);
739+
const callArgs = isWalletAddressStub.firstCall.args[0];
740+
assert.strictEqual(callArgs.derivedFromParentWithSeed, 'my-unique-smc-seed-abc123');
741+
});
742+
743+
it('should verify SMC wallet base address with derivedFromParentWithSeed', async function () {
744+
const baseAddress = '0xc012041dac143a59fa491db3a2b67b69bd78b685';
745+
const requestBody = {
746+
address: baseAddress,
747+
baseAddress: baseAddress,
748+
coinSpecific: {
749+
salt: '0x0',
750+
forwarderVersion: 5,
751+
feeAddress: '0xb1e725186990b86ca8efed08a3ccda9c9f400f09',
752+
},
753+
keychains: [
754+
{ pub: 'user_pub', commonKeychain },
755+
{ pub: 'backup_pub', commonKeychain },
756+
{ pub: 'bitgo_pub', commonKeychain },
757+
],
758+
index: 0,
759+
walletVersion: 6,
760+
derivedFromParentWithSeed: 'another-smc-seed-xyz789',
761+
};
762+
763+
const isWalletAddressStub = sinon.stub().resolves(true);
764+
const mockWallet = {
765+
baseCoin: {
766+
isWalletAddress: isWalletAddressStub,
767+
},
768+
};
769+
const walletsGetStub = sinon.stub().resolves(mockWallet);
770+
const mockWallets = {
771+
get: walletsGetStub,
772+
};
773+
const mockCoin = {
774+
wallets: sinon.stub().returns(mockWallets),
775+
};
776+
sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any);
777+
778+
const result = await agent
779+
.post('/api/v2/hteth/wallet/test-wallet-id/iswalletaddress')
780+
.set('Authorization', 'Bearer test_access_token_12345')
781+
.set('Content-Type', 'application/json')
782+
.send(requestBody);
783+
784+
assert.strictEqual(result.status, 200);
785+
assert.strictEqual(result.body, true);
786+
787+
// Verify that the derivedFromParentWithSeed was passed to isWalletAddress
788+
sinon.assert.calledOnce(isWalletAddressStub);
789+
const callArgs = isWalletAddressStub.firstCall.args[0];
790+
assert.strictEqual(callArgs.derivedFromParentWithSeed, 'another-smc-seed-xyz789');
791+
});
792+
793+
it('should work without derivedFromParentWithSeed for non-SMC wallets', async function () {
794+
const requestBody = {
795+
address: '0xa33f0975f53cdcfcc0cb564d25fb5be03b0651cf',
796+
baseAddress: '0xc012041dac143a59fa491db3a2b67b69bd78b685',
797+
coinSpecific: {
798+
forwarderVersion: 5,
799+
feeAddress: '0xb1e725186990b86ca8efed08a3ccda9c9f400f09',
800+
},
801+
keychains: [
802+
{ pub: 'user_pub', commonKeychain },
803+
{ pub: 'backup_pub', commonKeychain },
804+
{ pub: 'bitgo_pub', commonKeychain },
805+
],
806+
index: 7,
807+
walletVersion: 6,
808+
// No derivedFromParentWithSeed - this is a regular TSS wallet
809+
};
810+
811+
const isWalletAddressStub = sinon.stub().resolves(true);
812+
const mockWallet = {
813+
baseCoin: {
814+
isWalletAddress: isWalletAddressStub,
815+
},
816+
};
817+
const walletsGetStub = sinon.stub().resolves(mockWallet);
818+
const mockWallets = {
819+
get: walletsGetStub,
820+
};
821+
const mockCoin = {
822+
wallets: sinon.stub().returns(mockWallets),
823+
};
824+
sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any);
825+
826+
const result = await agent
827+
.post('/api/v2/hteth/wallet/test-wallet-id/iswalletaddress')
828+
.set('Authorization', 'Bearer test_access_token_12345')
829+
.set('Content-Type', 'application/json')
830+
.send(requestBody);
831+
832+
assert.strictEqual(result.status, 200);
833+
assert.strictEqual(result.body, true);
834+
835+
// Verify that derivedFromParentWithSeed is undefined for non-SMC wallets
836+
sinon.assert.calledOnce(isWalletAddressStub);
837+
const callArgs = isWalletAddressStub.firstCall.args[0];
838+
assert.strictEqual(callArgs.derivedFromParentWithSeed, undefined);
839+
});
840+
});
841+
643842
describe('Invalid Address Cases', function () {
644843
it('should return false for wrong address', async function () {
645844
const requestBody = {
@@ -797,6 +996,23 @@ describe('IsWalletAddress codec tests', function () {
797996
assert.ok(Array.isArray(result.body));
798997
});
799998

999+
it('should return 400 for invalid derivedFromParentWithSeed type', async function () {
1000+
const requestBody = {
1001+
address: '0x6069a4baf2360bf67a6d02a7fc43d8f3910016ae',
1002+
keychains: [{ pub: 'xpub1...' }],
1003+
derivedFromParentWithSeed: 12345, // Should be string, not number
1004+
};
1005+
1006+
const result = await agent
1007+
.post('/api/v2/hteth/wallet/test-wallet-id/iswalletaddress')
1008+
.set('Authorization', 'Bearer test_access_token_12345')
1009+
.set('Content-Type', 'application/json')
1010+
.send(requestBody);
1011+
1012+
assert.strictEqual(result.status, 400);
1013+
assert.ok(Array.isArray(result.body));
1014+
});
1015+
8001016
it('should handle isWalletAddress throwing InvalidAddressError', async function () {
8011017
const requestBody = {
8021018
address: '0xinvalid',

0 commit comments

Comments
 (0)