The first is instantiating a contract js instance and calling its methods through it. Doing it this way is helpful because we don't have to encode the data manually, but the contract instance will do it for us.
The second is making a raw transaction, which could be sent to the smart contract or a regular account. If you want to send a transaction this way to a smart contract, then the to
needs to be the contract address, and we need to add the data manually. In the data
field, we need to indicate what smart contract method we want to call and pass the parameters, all encoded.
For example, if the smart contract has a method caller()
that does not receive any parameter, then the data we need to send is the first 4 bytes of the keccak256
hash of "caller()". It's fc9c8d39
. This is the way of indicating which function from the smart contract to call. Check the hash yourself here: https://emn178.github.io/online-tools/keccak_256.html

So, our payload would look like this:
let tx = {
to: contractAddress,
value: amountInEther, // Optional when calling a contract, unless the function we are calling is `payable`
data: "0xfc9c8d39" // The `caller()` method in the smart contract will be executed
}
As you can see, it's a bit cumbersome to do it this way. That's why we better create a contract instance that will abstract and take care of all that for us. And that takes me to your second question.
new ethers.Contract(contractAddress, abi, signer)
is used to create a 'wrapper', a instance of the smart contract in Javascript, so you don't need to think about encoding and decoding data manually. Obviously, this instance is not the contract itself, but just an abstraction that allows us to seamlessly use the smart contract from Javascript. It makes the calls to the real contract located at contractAddress
, encoding the data
, which function to call, the parameters, etc. It's really convenient.
And your third question, yes, you can do it manually, calculation the keccak256
hash of the balanceOf(address)
, encoding the first 4 bytes of that hash with the address
param value, using something like:
const data = ethers.utils.defaultAbiCoder.encode(
["bytes4", "address"],
["0x70a08231", "0x6827b8f6cc60497d9bf5210d602C0EcaFDF7C405"]
);
The encoded data given the above values to encode will look like this:
0x70a08231000000000000000000000000000000000000000000000000000000000000000000000000000000006827b8f6cc60497d9bf5210d602c0ecafdf7c405
And adding it to our payload looks like this:
let tx = {
to: contractAddress,
data: "0x70a08231000000000000000000000000000000000000000000000000000000000000000000000000000000006827b8f6cc60497d9bf5210d602c0ecafdf7c405" // The `balanceOf(address)` method in the smart contract will be executed
}
But as you can see, it's cumbersome. Better get the ABI of the contract you need and instantiate a Javascript instance of it and use it, so it handles all the encoding/decoding details for you.