|
| 1 | +import { BuildTransactionError, InvalidParameterValueError } from '@bitgo/sdk-core'; |
| 2 | +import { coins, EthereumNetwork as EthLikeNetwork } from '@bitgo/statics'; |
| 3 | + |
| 4 | +import { ContractCall } from '../contractCall'; |
| 5 | +import { decodeConfidentialTransferData, isValidEthAddress, sendMultiSigData } from '../utils'; |
| 6 | +import { BaseNFTTransferBuilder } from './baseNFTTransferBuilder'; |
| 7 | +import { confidentialTransferWithProofMethodId, confidentialTransferWithProofTypes } from '../walletUtil'; |
| 8 | + |
| 9 | +export class TransferBuilderERC7984 extends BaseNFTTransferBuilder { |
| 10 | + private _encryptedHandle: string; |
| 11 | + private _inputProof: string; |
| 12 | + /** Plaintext token amount in base units, stored as metadata only (NOT included in calldata). */ |
| 13 | + private _amount: string; |
| 14 | + |
| 15 | + constructor(serializedData?: string) { |
| 16 | + super(serializedData); |
| 17 | + if (serializedData) { |
| 18 | + this.decodeTransferData(serializedData); |
| 19 | + } |
| 20 | + } |
| 21 | + |
| 22 | + coin(coin: string): this { |
| 23 | + this._coin = coins.get(coin); |
| 24 | + this._nativeCoinOperationHashPrefix = (this._coin.network as EthLikeNetwork).nativeCoinOperationHashPrefix; |
| 25 | + return this; |
| 26 | + } |
| 27 | + |
| 28 | + tokenContractAddress(address: string): this { |
| 29 | + if (isValidEthAddress(address)) { |
| 30 | + this._tokenContractAddress = address; |
| 31 | + return this; |
| 32 | + } |
| 33 | + throw new InvalidParameterValueError('Invalid address'); |
| 34 | + } |
| 35 | + |
| 36 | + /** |
| 37 | + * Set the plaintext transfer amount in base units. |
| 38 | + * |
| 39 | + * This value is stored as metadata only — it is NOT included in the on-chain |
| 40 | + * calldata, which carries only the encrypted form (encryptedHandle + inputProof). |
| 41 | + * Storing it here lets verifyTransaction confirm the signer's intent against |
| 42 | + * the original amount the client submitted. |
| 43 | + */ |
| 44 | + amount(amount: string): this { |
| 45 | + if (!/^\d+$/.test(amount) || BigInt(amount) <= 0n) { |
| 46 | + throw new InvalidParameterValueError('amount must be a positive integer string in base units'); |
| 47 | + } |
| 48 | + this._amount = amount; |
| 49 | + return this; |
| 50 | + } |
| 51 | + |
| 52 | + /** |
| 53 | + * Set the encrypted handle (bytes32 hex from WP) |
| 54 | + * Must be a 0x-prefixed 32-byte hex string (66 chars total) |
| 55 | + */ |
| 56 | + encryptedHandle(handle: string): this { |
| 57 | + if (!/^0x[0-9a-fA-F]{64}$/.test(handle)) { |
| 58 | + throw new InvalidParameterValueError('encryptedHandle must be a 0x-prefixed 32-byte hex string (66 characters)'); |
| 59 | + } |
| 60 | + this._encryptedHandle = handle; |
| 61 | + return this; |
| 62 | + } |
| 63 | + |
| 64 | + /** |
| 65 | + * Set the input proof (bytes hex from WP) |
| 66 | + * Must be a 0x-prefixed non-empty hex bytes string |
| 67 | + */ |
| 68 | + inputProof(proof: string): this { |
| 69 | + if (!/^0x[0-9a-fA-F]{2,}$/.test(proof) || proof.length < 4) { |
| 70 | + throw new InvalidParameterValueError('inputProof must be a 0x-prefixed non-empty hex bytes string'); |
| 71 | + } |
| 72 | + this._inputProof = proof; |
| 73 | + return this; |
| 74 | + } |
| 75 | + |
| 76 | + getIsFirstSigner(): boolean { |
| 77 | + return false; |
| 78 | + } |
| 79 | + |
| 80 | + build(): string { |
| 81 | + this.validateMandatoryFields(); |
| 82 | + const contractCall = new ContractCall(confidentialTransferWithProofMethodId, confidentialTransferWithProofTypes, [ |
| 83 | + this._toAddress, |
| 84 | + this._encryptedHandle, |
| 85 | + this._inputProof, |
| 86 | + ]); |
| 87 | + return contractCall.serialize(); |
| 88 | + } |
| 89 | + |
| 90 | + signAndBuild(chainId: string): string { |
| 91 | + if (!Number.isInteger(this._sequenceId)) { |
| 92 | + throw new BuildTransactionError('Missing mandatory field: contract sequence id'); |
| 93 | + } |
| 94 | + this._chainId = chainId; |
| 95 | + this.validateMandatoryFields(); |
| 96 | + this._data = this.build(); |
| 97 | + |
| 98 | + return sendMultiSigData( |
| 99 | + this._tokenContractAddress, |
| 100 | + '0', |
| 101 | + this._data, |
| 102 | + this._expirationTime, |
| 103 | + this._sequenceId, |
| 104 | + this.getSignature() |
| 105 | + ); |
| 106 | + } |
| 107 | + |
| 108 | + private validateMandatoryFields(): void { |
| 109 | + if (!this._toAddress) { |
| 110 | + throw new BuildTransactionError('Missing mandatory field: destination (to) address'); |
| 111 | + } |
| 112 | + if (!this._tokenContractAddress) { |
| 113 | + throw new BuildTransactionError('Missing mandatory field: token contract address'); |
| 114 | + } |
| 115 | + if (!this._encryptedHandle) { |
| 116 | + throw new BuildTransactionError('Missing mandatory field: encryptedHandle'); |
| 117 | + } |
| 118 | + if (!this._inputProof || this._inputProof === '0x' || !/^0x[0-9a-fA-F]{2,}$/.test(this._inputProof)) { |
| 119 | + throw new BuildTransactionError('Missing mandatory field: inputProof'); |
| 120 | + } |
| 121 | + } |
| 122 | + |
| 123 | + private decodeTransferData(data: string): void { |
| 124 | + const transferData = decodeConfidentialTransferData(data); |
| 125 | + this._toAddress = transferData.toAddress; |
| 126 | + this._tokenContractAddress = transferData.tokenContractAddress; |
| 127 | + this._encryptedHandle = transferData.encryptedHandle; |
| 128 | + this._inputProof = transferData.inputProof; |
| 129 | + this._expirationTime = parseInt(transferData.expireTime, 10); |
| 130 | + this._sequenceId = parseInt(transferData.sequenceId, 10); |
| 131 | + this._signature = transferData.signature; |
| 132 | + } |
| 133 | +} |
0 commit comments